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

### Lium

The Lium backend is experimental. 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>

To restrict provisioning to specific regions, set `regions`, for example `regions: [us-ca, de-he]`.
Omit it to use all available regions.

Private Docker images are supported through the run configuration's
[`registry_auth`](../reference/dstack.yml/task.md#registry_auth) property.

!!! info "Storage and supported features"
Local disk is unencrypted and is deleted with the pod. The backend does not support
GPU blocks, instance volumes, or multi-node runs.

Lium splits local disk between `/root` (two thirds) and the container filesystem
(one third). The total allocation is not available in a single directory.

Persistent [network volumes](volumes.md#network-volumes) use S3-backed storage and support
jobs running as `root` only. You can attach one volume per pod at the path specified in
the run configuration. Storage grows with usage, and volumes can be attached in any Lium
region. Omit `size` and `region` from the volume configuration.
Do not rely on local-disk POSIX semantics or concurrent-writer consistency.

### 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: 18 additions & 2 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,22 @@ 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 and grow with usage. Omit `size` and `region`:

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

A run can mount one volume. Lium forces files on the volume to be owned by `root`,
so `dstack` rejects volume mounts for non-root jobs. Jobs without network volumes
can run as non-root users normally.

Lium bills storage and read traffic separately from compute. Use these volumes for
datasets and checkpoints; do not assume local-disk performance or POSIX semantics.

??? 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 @@ -287,7 +303,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
11 changes: 11 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,17 @@ The `volume` configuration type allows creating, registering, and updating [volu
backend:
required: true

=== "Lium"

Lium volumes grow with usage and do not take `size` or `region`.
A run can mount one volume and must run as `root`.

#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
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ dependencies = [
"python-multipart>=0.0.16",
"filelock",
"psutil",
"gpuhunt==0.1.33",
# TODO: Merge/release gpuhunt#259 and pin the released version.
"gpuhunt @ git+https://github.com/Datura-ai/gpuhunt.git@lium-provider",
"argcomplete>=3.5.0",
"ignore-python>=0.2.0",
"apscheduler<4",
Expand All @@ -57,6 +58,10 @@ dstack = "dstack._internal.cli.main:main"
[tool.hatch.version]
path = "src/dstack/version.py"

[tool.hatch.metadata]
# TODO: Remove after pinning the released gpuhunt package.
allow-direct-references = true

[tool.hatch.build.targets.sdist]
artifacts = [
"src/dstack/_internal/server/statics/**",
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