Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/types/backend.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ declare type TBackendType =
| 'remote'
| 'oci'
| 'runpod'
| 'seeweb'
| 'tensordock'
| 'vastai'
| 'cloudrift'
Expand Down
28 changes: 28 additions & 0 deletions mkdocs/docs/concepts/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,34 @@ projects:

</div>

### Seeweb

Log into the [Seeweb](https://www.seeweb.it/en) control panel, go to the
`Calculation` section, open `API Token`, and click `New token` to generate one.

Then, go ahead and configure the backend:

<div editor-title="~/.dstack/server/config.yml">

```yaml
projects:
- name: main
backends:
- type: seeweb
creds:
type: api_token
api_token: ...
```

</div>

By default, `dstack` uses all Seeweb regions. To limit them, specify `regions`
(for example, `it-fr2`).

`dstack` checks [live plan capacity](https://docs.seeweb.it/en/hosting/cloudserver/rest-api/API-Endpoints/Plan/)
before provisioning. A compatible NVIDIA driver image is selected from the images allowed by the
chosen plan.

### OCI

There are two ways to configure OCI: using client credentials or using the default credentials.
Expand Down
17 changes: 17 additions & 0 deletions mkdocs/docs/reference/server/config.yml.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,23 @@ to configure [backends](../../concepts/backends.md) and other [server-level sett
type:
required: true

##### `projects[n].backends[type=seeweb]` { #seeweb data-toc-label="seeweb" }

#SCHEMA# dstack._internal.core.backends.seeweb.models.SeewebBackendConfigWithCreds
overrides:
show_root_heading: false
type:
required: true
item_id_prefix: seeweb-

###### `projects[n].backends[type=seeweb].creds` { #seeweb-creds data-toc-label="creds" }

#SCHEMA# dstack._internal.core.backends.seeweb.models.SeewebAPITokenCreds
overrides:
show_root_heading: false
type:
required: true

##### `projects[n].backends[type=amddevcloud]` { #amddevcloud data-toc-label="amddevcloud" }

#SCHEMA# dstack._internal.core.backends.digitalocean_base.models.BaseDigitalOceanBackendConfigWithCreds
Expand Down
7 changes: 7 additions & 0 deletions src/dstack/_internal/core/backends/configurators.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@
except ImportError:
pass

try:
from dstack._internal.core.backends.seeweb.configurator import SeewebConfigurator

_CONFIGURATOR_CLASSES.append(SeewebConfigurator)
except ImportError:
pass

try:
from dstack._internal.core.backends.vastai.configurator import VastAIConfigurator

Expand Down
7 changes: 7 additions & 0 deletions src/dstack/_internal/core/backends/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@
RunpodBackendConfig,
RunpodBackendConfigWithCreds,
)
from dstack._internal.core.backends.seeweb.models import (
SeewebBackendConfig,
SeewebBackendConfigWithCreds,
)
from dstack._internal.core.backends.slurm.models import (
SlurmBackendConfig,
SlurmBackendConfigWithCreds,
Expand Down Expand Up @@ -105,6 +109,7 @@
NebiusBackendConfig,
OCIBackendConfig,
RunpodBackendConfig,
SeewebBackendConfig,
TensorDockBackendConfig,
VastAIBackendConfig,
VerdaBackendConfig,
Expand Down Expand Up @@ -133,6 +138,7 @@
OCIBackendConfigWithCreds,
NebiusBackendConfigWithCreds,
RunpodBackendConfigWithCreds,
SeewebBackendConfigWithCreds,
TensorDockBackendConfigWithCreds,
VastAIBackendConfigWithCreds,
VultrBackendConfigWithCreds,
Expand All @@ -159,6 +165,7 @@
OCIBackendConfigWithCreds,
NebiusBackendFileConfigWithCreds,
RunpodBackendConfigWithCreds,
SeewebBackendConfigWithCreds,
TensorDockBackendConfigWithCreds,
VastAIBackendConfigWithCreds,
VultrBackendConfigWithCreds,
Expand Down
Empty file.
231 changes: 231 additions & 0 deletions src/dstack/_internal/core/backends/seeweb/api_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import hashlib
from dataclasses import dataclass
from typing import Any, Dict, Optional

import requests

from dstack._internal.core.errors import (
BackendError,
BackendInvalidCredentialsError,
NoCapacityError,
)
from dstack._internal.utils.logging import get_logger

logger = get_logger(__name__)

API_URL = "https://api.seeweb.it/ecs/v2"
TIMEOUT = 30


@dataclass(frozen=True)
class SeewebPlanAvailability:
regions: frozenset[str]
images: tuple[str, ...]


class SeewebApiClient:
"""Minimal client for the Seeweb ECS REST API (https://api.seeweb.it/ecs/v2).

Uses ``requests`` directly instead of the ``ecsapi`` SDK: ``ecsapi`` requires pydantic v2,
while dstack is pinned to pydantic v1, so the two cannot share an environment.
"""

def __init__(self, api_token: str):
self.api_token = api_token

def _request(
self, method: str, path: str, json: Optional[Dict[str, Any]] = None
) -> requests.Response:
try:
response = requests.request(
method=method,
url=API_URL + path,
headers={"X-APITOKEN": self.api_token},
json=json,
timeout=TIMEOUT,
)
except requests.RequestException as e:
raise BackendError(f"Seeweb API request failed: {e}") from e
if response.status_code in (requests.codes.unauthorized, requests.codes.forbidden):
raise BackendInvalidCredentialsError(_response_message(response))
return response

def validate_api_key(self) -> bool:
try:
response = self._request("GET", "/regions")
except BackendInvalidCredentialsError:
return False
_raise_api_error(response)
return True

def get_or_create_ssh_key(self, public_key: str) -> str:
"""Return the label of a Seeweb SSH key matching ``public_key``, creating it if needed."""
canonical_key = _canonicalize_ssh_public_key(public_key)
label = "dstack-" + hashlib.sha256(canonical_key.encode()).hexdigest()[:20]
response = self._request("GET", "/sshkeys")
data = _response_data(response)
for key in data.get("pubkeys", []):
if not isinstance(key, dict):
continue
try:
existing_key = _canonicalize_ssh_public_key(str(key.get("key", "")))
except ValueError:
continue
if existing_key == canonical_key:
existing_label = key.get("label")
if isinstance(existing_label, str):
return existing_label
created = self._request(
"POST",
"/sshkeys",
json={"key": public_key.strip(), "label": label},
)
_response_data(created)
return label

def get_available_plans(self) -> dict[str, SeewebPlanAvailability]:
"""Return current regions and allowed images for each activatable plan."""
response = self._request("GET", "/plans/availables")
data = _response_data(response)
available = {}
for plan in data.get("plans", []):
if not isinstance(plan, dict) or not isinstance(plan.get("name"), str):
continue
regions = set()
raw_regions = plan.get("region_availables") or plan.get("region_available") or []
if isinstance(raw_regions, list):
for region in raw_regions:
if isinstance(region, dict) and isinstance(region.get("region"), str):
regions.add(region["region"])
images = []
raw_images = plan.get("os_availables") or []
if isinstance(raw_images, list):
for image in raw_images:
if (
isinstance(image, dict)
and isinstance(image.get("name"), str)
and image.get("active_flag") is not False
):
images.append(image["name"])
available[plan["name"]] = SeewebPlanAvailability(
regions=frozenset(regions),
images=tuple(images),
)
return available

def get_available_plan_regions(self) -> set[tuple[str, str]]:
"""Return the set of ``(plan_name, region)`` pairs that can currently be provisioned.

Seeweb's ``/plans`` catalog lists a plan's regions, but a plan/region is only actually
creatable if it appears in ``/plans/availables`` ``region_availables`` (i.e. has capacity).
"""
return {
(plan_name, region)
for plan_name, availability in self.get_available_plans().items()
for region in availability.regions
}

def create_server(self, body: Dict[str, Any]) -> tuple[Dict[str, Any], Optional[int]]:
"""Create a server. Returns ``(server_dict, action_id)``.

Capacity errors become ``NoCapacityError`` so dstack can fall through to another offer.
"""
response = self._request("POST", "/servers", json=body)
data = _response_data(response, capacity_errors=True)
if not isinstance(data, dict) or "server" not in data:
raise BackendError("Seeweb create-server response did not include a server")
server = data["server"]
if not isinstance(server, dict) or not isinstance(server.get("name"), str):
raise BackendError("Seeweb create-server response contained an invalid server")
action = data.get("action_id", data.get("action"))
if isinstance(action, dict):
action = action.get("id")
action_id = action if isinstance(action, int) else None
return server, action_id

def get_action(self, action_id: int) -> Optional[Dict[str, Any]]:
response = self._request("GET", f"/actions/{action_id}")
if response.status_code == requests.codes.not_found:
return None
data = _response_data(response)
action = data.get("action")
return action if isinstance(action, dict) else None

def get_server(self, name: str) -> Optional[Dict[str, Any]]:
response = self._request("GET", f"/servers/{name}")
if response.status_code == requests.codes.not_found:
return None
data = _response_data(response)
server = data.get("server")
return server if isinstance(server, dict) else None

def delete_server(self, name: str) -> None:
"""Delete a server, treating a missing server as success (idempotent)."""
response = self._request("DELETE", f"/servers/{name}")
if response.status_code == requests.codes.not_found:
logger.debug("Seeweb server %s already deleted", name)
return
_response_data(response)


def _response_data(
response: requests.Response, *, capacity_errors: bool = False
) -> Dict[str, Any]:
try:
data = response.json()
except requests.JSONDecodeError as e:
if not response.ok:
raise BackendError(_response_message(response)) from e
raise BackendError("Seeweb API returned a non-JSON response") from e
if not isinstance(data, dict):
raise BackendError("Seeweb API returned an invalid response")
status = str(data.get("status", "ok")).lower()
if not response.ok or status not in {"ok", "success"}:
message = _response_message(response, data)
if capacity_errors and _is_capacity_error(message):
raise NoCapacityError(message)
raise BackendError(message)
return data


def _raise_api_error(response: requests.Response) -> None:
_response_data(response)


def _response_message(response: requests.Response, data: Optional[Dict[str, Any]] = None) -> str:
if data is None:
try:
parsed = response.json()
data = parsed if isinstance(parsed, dict) else None
except requests.JSONDecodeError:
data = None
if data is not None:
for field in ("message", "error", "detail"):
value = data.get(field)
if value:
return str(value)
text = response.text.strip()
return text if text else f"Seeweb API returned HTTP {response.status_code}"


def _is_capacity_error(message: str) -> bool:
normalized = message.lower()
return any(
marker in normalized
for marker in (
"not available",
"no capacity",
"lack of resources",
"not activatable",
"no host",
)
)


def _canonicalize_ssh_public_key(public_key: str) -> str:
parts = public_key.strip().split()
if len(parts) < 2 or not (
parts[0].startswith("ssh-") or parts[0].startswith("ecdsa-") or parts[0].startswith("sk-")
):
raise ValueError("Invalid SSH public key")
return " ".join(parts[:2])
16 changes: 16 additions & 0 deletions src/dstack/_internal/core/backends/seeweb/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from dstack._internal.core.backends.base.backend import Backend
from dstack._internal.core.backends.seeweb.compute import SeewebCompute
from dstack._internal.core.backends.seeweb.models import SeewebConfig
from dstack._internal.core.models.backends.base import BackendType


class SeewebBackend(Backend):
TYPE = BackendType.SEEWEB
COMPUTE_CLASS = SeewebCompute

def __init__(self, config: SeewebConfig):
self.config = config
self._compute = SeewebCompute(self.config)

def compute(self) -> SeewebCompute:
return self._compute
Loading