Skip to content
Open
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 @@ -9,6 +9,7 @@ declare type TBackendType =
| 'gcp'
| 'kubernetes'
| 'lambda'
| 'lium'
| 'local'
| 'nebius'
| 'remote'
Expand Down
19 changes: 19 additions & 0 deletions mkdocs/docs/concepts/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -1506,6 +1506,25 @@ projects:
* Creating volumes: `read:volumes`, `write:volumes`, and `delete:volumes`
* Registering existing volumes: `read:volumes`

### Lium

Create an API key under **Access → API Keys** in the
[Lium dashboard](https://lium.io/ssh-keys), then configure the backend:

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

```yaml
projects:
- name: main
backends:
- type: lium
creds:
type: api_key
api_key: YOUR_LIUM_API_KEY
```

</div>

### Runpod

Log into your [Runpod](https://www.runpod.io/console/) console, click Settings in the sidebar, expand the `API Keys` section, and click
Expand Down
20 changes: 16 additions & 4 deletions mkdocs/docs/concepts/volumes.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Useful as a cache for cloud fleets or for persistent storage with SSH fleets.

## Network volumes

> Network volumes are currently supported for the `aws`, `gcp`, `runpod`, `kubernetes`, and `daytona` backends.
> Network volumes are currently supported for the `aws`, `gcp`, `runpod`, `kubernetes`, `daytona`, and `lium` backends.

### Apply a configuration

Expand Down Expand Up @@ -52,6 +52,18 @@ If you use this configuration, `dstack` will create a new volume based on the sp
name: my-volume
```

??? info "Lium"
Lium volumes use S3-backed storage, grow with usage, and can be mounted in any Lium region.
Omit `size` and `region`. Each run can mount one volume.

```yaml
type: volume
backend: lium
name: my-volume
```

> Files on Lium volumes are owned by root and accessible to all users in the container.

??? info "Kubernetes"
Set `region` to a kubeconfig context name enabled in the [backend configuration](backends.md#kubernetes).
Omit it only for legacy configurations without `contexts`.
Expand Down Expand Up @@ -143,7 +155,7 @@ For all volume configuration options, refer to the [reference](../reference/dsta

### Attach a volume { #attach-network-volume }

Dev environments, tasks, and services let you attach any number of network volumes.
Dev environments, tasks, and services let you attach network volumes.
To attach a network volume, simply specify its name using the `volumes` property
and specify where to mount its contents:

Expand Down Expand Up @@ -276,7 +288,7 @@ If you've registered an existing volume, it will be de-registered with `dstack`
If you don't want to limit a run to one particular region, you can create different volumes for different regions
and specify them for the same mount point as [documented above](#attach-network-volume).

Daytona volumes can be mounted in any Daytona region.
Daytona and Lium volumes can be mounted in any region of their respective backend.

??? info "Can I attach network volumes to multiple runs or instances?"
The `runpod` and `daytona` backends support mounting the same volume in multiple runs at the same time.
Expand All @@ -287,7 +299,7 @@ If you've registered an existing volume, it will be de-registered with `dstack`
Instance volumes allow mapping any directory on the instance where the run is executed to any path inside the container.
This means that the data in instance volumes is persisted only if the run is executed on the same instance.

> Instance volumes are currently supported for all backends except `daytona`, `runpod`, and `vastai`, and can also be used with [SSH fleets](fleets.md#ssh-fleets).
> Instance volumes are currently supported for all backends except `daytona`, `lium`, `runpod`, and `vastai`, and can also be used with [SSH fleets](fleets.md#ssh-fleets).

### Attach a volume

Expand Down
8 changes: 8 additions & 0 deletions mkdocs/docs/reference/dstack.yml/volume.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ The `volume` configuration type allows creating, registering, and updating [volu
backend:
required: true

=== "Lium"

#SCHEMA# dstack._internal.core.models.volumes.LiumVolumeConfiguration
overrides:
show_root_heading: false
backend:
required: true

=== "Kubernetes"

Kubernetes backend volumes are mapped to [`PersistentVolumeClaim`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#persistentvolumeclaims) objects.
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 @@ -159,6 +159,23 @@ to configure [backends](../../concepts/backends.md) and other [server-level sett
type:
required: true

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

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

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

#SCHEMA# dstack._internal.core.backends.lium.models.LiumAPIKeyCreds
overrides:
show_root_heading: false
type:
required: true

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

#SCHEMA# dstack._internal.core.backends.nebius.models.NebiusBackendFileConfigWithCreds
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"python-multipart>=0.0.16",
"filelock",
"psutil",
"gpuhunt==0.1.33",
"gpuhunt==0.1.34",
"argcomplete>=3.5.0",
"ignore-python>=0.2.0",
"apscheduler<4",
Expand Down Expand Up @@ -112,6 +112,7 @@ include = [
"src/dstack/_internal/core/backends/aws",
"src/dstack/_internal/core/backends/daytona",
"src/dstack/_internal/core/backends/kubernetes",
"src/dstack/_internal/core/backends/lium",
"src/dstack/_internal/core/backends/runpod",
"src/dstack/_internal/core/backends/slurm",
"src/dstack/_internal/cli/services/configurators",
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 @@ -112,6 +112,13 @@
except ImportError:
pass

try:
from dstack._internal.core.backends.lium.configurator import LiumConfigurator

_CONFIGURATOR_CLASSES.append(LiumConfigurator)
except ImportError:
pass

try:
from dstack._internal.core.backends.nebius.configurator import (
NebiusConfigurator,
Expand Down
Empty file.
176 changes: 176 additions & 0 deletions src/dstack/_internal/core/backends/lium/api_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import re
from typing import Any, Optional
from urllib.parse import quote

import requests

from dstack._internal.core.errors import ComputeError

API_URL = "https://lium.io/api"
REQUEST_TIMEOUT_SECONDS = 30


class LiumAPIError(ComputeError):
def __init__(
self,
message: str,
status_code: Optional[int] = None,
code: Optional[str] = None,
idempotent_replayed: bool = False,
):
super().__init__(message)
self.status_code = status_code
self.code = code
self.idempotent_replayed = idempotent_replayed


class LiumAPIClient:
def __init__(self, api_key: str):
self.api_key = api_key

def get_user(self) -> dict[str, Any]:
return _object(self._make_request("GET", "/users/me"))

def get_version(self) -> dict[str, Any]:
return _object(self._make_request("GET", "/version"))

def get_pods(self) -> list[dict[str, Any]]:
return _objects(self._make_request("GET", "/pods"))

def get_account_events(self, since: Optional[str] = None) -> list[dict[str, Any]]:
# The account event log retains pod names and IDs after deletion.
params: dict[str, Any] = {"limit": 1000}
if since is not None:
params["since"] = since
return _objects(self._make_request("GET", "/users/me/events", params=params))

def get_pod(self, pod_id: str) -> Optional[dict[str, Any]]:
try:
return _object(self._make_request("GET", f"/pods/{quote(pod_id, safe='')}"))
except LiumAPIError as e:
if e.status_code == 404:
return None
raise

def create_template(self, payload: dict[str, Any]) -> dict[str, Any]:
return _object(self._make_request("POST", "/templates", json=payload))

def get_templates(self) -> list[dict[str, Any]]:
return _objects(self._make_request("GET", "/templates"))

def delete_template(self, template_id: str) -> None:
self._delete(f"/templates/{quote(template_id, safe='')}")

def create_docker_credential(self, payload: dict[str, Any]) -> dict[str, Any]:
return _object(self._make_request("POST", "/docker-credentials/", json=payload))

def delete_docker_credential(self, credential_id: str) -> None:
self._delete(f"/docker-credentials/{quote(credential_id, safe='')}")

def rent_executor(
self, executor_id: str, payload: dict[str, Any], idempotency_key: str
) -> dict[str, Any]:
# Retries must keep both the key and the request body unchanged. The caller
# reconciles uncertain outcomes through the pod list before another rental.
return _object(
self._make_request(
"POST",
f"/executors/{quote(executor_id, safe='')}/rent",
json=payload,
headers={"Idempotency-Key": idempotency_key},
)
)

def delete_pod(self, pod_id: str) -> None:
self._delete(f"/pods/{quote(pod_id, safe='')}")

def create_volume(self, name: str) -> dict[str, Any]:
return _object(self._make_request("POST", "/volumes", json={"name": name}))

def get_volumes(self) -> list[dict[str, Any]]:
return _objects(self._make_request("GET", "/volumes"))

def get_volume(self, volume_id: str) -> Optional[dict[str, Any]]:
try:
return _object(self._make_request("GET", f"/volumes/{quote(volume_id, safe='')}"))
except LiumAPIError as e:
if e.status_code == 404:
return None
raise

def delete_volume(self, volume_id: str) -> None:
self._delete(f"/volumes/{quote(volume_id, safe='')}")

def _delete(self, path: str) -> None:
try:
self._make_request("DELETE", path)
except LiumAPIError as e:
if e.status_code != 404:
raise

def _make_request(
self,
method: str,
path: str,
*,
json: Optional[dict[str, Any]] = None,
headers: Optional[dict[str, str]] = None,
params: Optional[dict[str, Any]] = None,
) -> Any:
try:
response = requests.request(
method=method,
url=API_URL + path,
headers={"X-API-Key": self.api_key, **(headers or {})},
json=json,
params=params,
timeout=REQUEST_TIMEOUT_SECONDS,
allow_redirects=False,
)
except requests.Timeout:
raise LiumAPIError("Lium API request timed out") from None
except requests.RequestException:
raise LiumAPIError("Lium API request failed") from None
if not 200 <= response.status_code < 300:
code = None
try:
data = response.json()
except ValueError:
data = None
if isinstance(data, dict) and isinstance(data.get("error"), dict):
value = data["error"].get("code")
if (
isinstance(value, str)
and re.fullmatch(r"[a-z][a-z0-9_]{0,63}", value)
and self.api_key not in value
):
code = value
# Show only a short machine code; provider messages may reflect credentials.
message = f"Lium API returned HTTP {response.status_code}"
if code is not None:
message += f" ({code})"
raise LiumAPIError(
message,
status_code=response.status_code,
code=code,
idempotent_replayed=response.headers.get("Idempotent-Replayed", "").lower()
== "true",
)
if method == "DELETE" or not response.content:
return None
try:
return response.json()
except ValueError:
raise LiumAPIError("Lium API returned a non-JSON response") from None


def _object(data: Any) -> dict[str, Any]:
if not isinstance(data, dict):
raise LiumAPIError("Lium API returned an invalid object response")
return data


def _objects(data: Any) -> list[dict[str, Any]]:
if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
raise LiumAPIError("Lium API returned an invalid list response")
return data
16 changes: 16 additions & 0 deletions src/dstack/_internal/core/backends/lium/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.lium.compute import LiumCompute
from dstack._internal.core.backends.lium.models import LiumConfig
from dstack._internal.core.models.backends.base import BackendType


class LiumBackend(Backend):
TYPE = BackendType.LIUM
COMPUTE_CLASS = LiumCompute

def __init__(self, config: LiumConfig):
self.config = config
self._compute = LiumCompute(self.config)

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