From 628bb8b29721123757f931bc4606185e6c2f8349 Mon Sep 17 00:00:00 2001
From: Victor Skvortsov
Date: Mon, 19 Jan 2026 15:51:14 +0500
Subject: [PATCH 001/640] Fix missing instance lock in delete_fleets (#3471)
* Fix missing instance lock in delete_fleets
* Handle terminating deleted instances
* Fix comment
* Fix log message
---
.../background/tasks/process_instances.py | 12 ++--
.../_internal/server/services/fleets.py | 59 ++++++++++++-------
.../tasks/test_process_instances.py | 28 +++++++++
3 files changed, 72 insertions(+), 27 deletions(-)
diff --git a/src/dstack/_internal/server/background/tasks/process_instances.py b/src/dstack/_internal/server/background/tasks/process_instances.py
index 9a14bdc30d..454d6ee18a 100644
--- a/src/dstack/_internal/server/background/tasks/process_instances.py
+++ b/src/dstack/_internal/server/background/tasks/process_instances.py
@@ -11,7 +11,7 @@
from pydantic import ValidationError
from sqlalchemy import and_, delete, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import joinedload, with_loader_criteria
+from sqlalchemy.orm import joinedload
from dstack._internal import settings
from dstack._internal.core.backends.base.compute import (
@@ -218,9 +218,8 @@ async def _process_instance(session: AsyncSession, instance: InstanceModel):
.options(joinedload(InstanceModel.project).joinedload(ProjectModel.backends))
.options(joinedload(InstanceModel.jobs).load_only(JobModel.id, JobModel.status))
.options(
- joinedload(InstanceModel.fleet).joinedload(FleetModel.instances),
- with_loader_criteria(
- InstanceModel, InstanceModel.deleted == False, include_aliases=True
+ joinedload(InstanceModel.fleet).joinedload(
+ FleetModel.instances.and_(InstanceModel.deleted == False)
),
)
.execution_options(populate_existing=True)
@@ -233,9 +232,8 @@ async def _process_instance(session: AsyncSession, instance: InstanceModel):
.options(joinedload(InstanceModel.project))
.options(joinedload(InstanceModel.jobs).load_only(JobModel.id, JobModel.status))
.options(
- joinedload(InstanceModel.fleet).joinedload(FleetModel.instances),
- with_loader_criteria(
- InstanceModel, InstanceModel.deleted == False, include_aliases=True
+ joinedload(InstanceModel.fleet).joinedload(
+ FleetModel.instances.and_(InstanceModel.deleted == False)
),
)
.execution_options(populate_existing=True)
diff --git a/src/dstack/_internal/server/services/fleets.py b/src/dstack/_internal/server/services/fleets.py
index 95ae519d07..588f34698d 100644
--- a/src/dstack/_internal/server/services/fleets.py
+++ b/src/dstack/_internal/server/services/fleets.py
@@ -42,7 +42,12 @@
)
from dstack._internal.core.models.projects import Project
from dstack._internal.core.models.resources import ResourcesSpec
-from dstack._internal.core.models.runs import JobProvisioningData, Requirements, get_policy_map
+from dstack._internal.core.models.runs import (
+ JobProvisioningData,
+ Requirements,
+ RunStatus,
+ get_policy_map,
+)
from dstack._internal.core.models.users import GlobalRole
from dstack._internal.core.services import validate_dstack_resource_name
from dstack._internal.core.services.diff import ModelDiff, copy_model, diff_models
@@ -53,6 +58,7 @@
JobModel,
MemberModel,
ProjectModel,
+ RunModel,
UserModel,
)
from dstack._internal.server.services import events
@@ -613,48 +619,61 @@ async def delete_fleets(
instance_nums: Optional[List[int]] = None,
):
res = await session.execute(
- select(FleetModel)
+ select(FleetModel.id)
.where(
FleetModel.project_id == project.id,
FleetModel.name.in_(names),
FleetModel.deleted == False,
)
- .options(joinedload(FleetModel.instances))
+ .order_by(FleetModel.id) # take locks in order
+ .with_for_update(key_share=True)
)
- fleet_models = res.scalars().unique().all()
- fleets_ids = sorted([f.id for f in fleet_models])
- instances_ids = sorted([i.id for f in fleet_models for i in f.instances])
- await session.commit()
- logger.info("Deleting fleets: %s", [v.name for v in fleet_models])
+ fleets_ids = list(res.scalars().unique().all())
+ res = await session.execute(
+ select(InstanceModel.id)
+ .where(
+ InstanceModel.fleet_id.in_(fleets_ids),
+ InstanceModel.deleted == False,
+ )
+ .order_by(InstanceModel.id) # take locks in order
+ .with_for_update(key_share=True)
+ )
+ instances_ids = list(res.scalars().unique().all())
+ if is_db_sqlite():
+ # Start new transaction to see committed changes after lock
+ await session.commit()
async with (
get_locker(get_db().dialect_name).lock_ctx(FleetModel.__tablename__, fleets_ids),
get_locker(get_db().dialect_name).lock_ctx(InstanceModel.__tablename__, instances_ids),
):
- # Refetch after lock
- # TODO: Lock instances with FOR UPDATE?
- # TODO: Do not lock fleet when deleting only instances
+ # Refetch after lock.
+ # TODO: Do not lock fleet when deleting only instances.
res = await session.execute(
select(FleetModel)
- .where(
- FleetModel.project_id == project.id,
- FleetModel.name.in_(names),
- FleetModel.deleted == False,
- )
+ .where(FleetModel.id.in_(fleets_ids))
.options(
- selectinload(FleetModel.instances)
+ joinedload(FleetModel.instances.and_(InstanceModel.id.in_(instances_ids)))
.joinedload(InstanceModel.jobs)
.load_only(JobModel.id)
)
- .options(selectinload(FleetModel.runs))
+ .options(
+ joinedload(
+ FleetModel.runs.and_(RunModel.status.not_in(RunStatus.finished_statuses()))
+ )
+ )
.execution_options(populate_existing=True)
- .order_by(FleetModel.id) # take locks in order
- .with_for_update(key_share=True)
)
fleet_models = res.scalars().unique().all()
fleets = [fleet_model_to_fleet(m) for m in fleet_models]
for fleet in fleets:
if fleet.spec.configuration.ssh_config is not None:
_check_can_manage_ssh_fleets(user=user, project=project)
+ if instance_nums is None:
+ logger.info("Deleting fleets: %s", [f.name for f in fleet_models])
+ else:
+ logger.info(
+ "Deleting fleets %s instances %s", [f.name for f in fleet_models], instance_nums
+ )
for fleet_model in fleet_models:
_terminate_fleet_instances(fleet_model=fleet_model, instance_nums=instance_nums)
# TERMINATING fleets are deleted by process_fleets after instances are terminated
diff --git a/src/tests/_internal/server/background/tasks/test_process_instances.py b/src/tests/_internal/server/background/tasks/test_process_instances.py
index a72dc0c165..38bffc4421 100644
--- a/src/tests/_internal/server/background/tasks/test_process_instances.py
+++ b/src/tests/_internal/server/background/tasks/test_process_instances.py
@@ -597,6 +597,34 @@ async def test_terminate(self, test_db, session: AsyncSession):
assert instance.deleted_at is not None
assert instance.finished_at is not None
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_terminates_terminating_deleted_instance(self, test_db, session: AsyncSession):
+ # There was a race condition when instance could stay in Terminating while marked as deleted.
+ # TODO: Drop this after all such "bad" instances are processed.
+ project = await create_project(session=session)
+ instance = await create_instance(
+ session=session, project=project, status=InstanceStatus.TERMINATING
+ )
+ instance.deleted = True
+ instance.termination_reason = InstanceTerminationReason.IDLE_TIMEOUT
+ instance.last_job_processed_at = instance.deleted_at = (
+ get_current_datetime() + dt.timedelta(minutes=-19)
+ )
+ await session.commit()
+
+ with self.mock_terminate_in_backend() as mock:
+ await process_instances()
+ mock.assert_called_once()
+
+ await session.refresh(instance)
+
+ assert instance is not None
+ assert instance.status == InstanceStatus.TERMINATED
+ assert instance.deleted == True
+ assert instance.deleted_at is not None
+ assert instance.finished_at is not None
+
@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
@pytest.mark.parametrize(
From a07ef352779fad5aa2fdc0334314becc940ddd47 Mon Sep 17 00:00:00 2001
From: Victor Skvortsov
Date: Mon, 19 Jan 2026 17:16:21 +0500
Subject: [PATCH 002/640] Optimize list and get fleets (#3472)
* Do not include deleted instances when listing fleets
* Do not include deleted instances when getting fleet
* Optimize select in generate_volume_name
---
src/dstack/_internal/server/routers/fleets.py | 2 ++
.../_internal/server/services/fleets.py | 27 ++++++++++++-------
.../_internal/server/services/volumes.py | 9 +++++--
3 files changed, 27 insertions(+), 11 deletions(-)
diff --git a/src/dstack/_internal/server/routers/fleets.py b/src/dstack/_internal/server/routers/fleets.py
index d423134675..a436d1123a 100644
--- a/src/dstack/_internal/server/routers/fleets.py
+++ b/src/dstack/_internal/server/routers/fleets.py
@@ -47,6 +47,7 @@ async def list_fleets(
"""
Returns all fleets and instances within them visible to user sorted by descending `created_at`.
`project_name` and `only_active` can be specified as filters.
+ Includes only active fleet instances. To list all fleet instances, use `/api/instances/list`.
The results are paginated. To get the next page, pass `created_at` and `id` of
the last fleet from the previous page as `prev_created_at` and `prev_id`.
@@ -72,6 +73,7 @@ async def list_project_fleets(
):
"""
Returns all fleets in the project.
+ Includes only active fleet instances. To list all fleet instances, use `/api/instances/list`.
"""
_, project = user_project
return CustomORJSONResponse(
diff --git a/src/dstack/_internal/server/services/fleets.py b/src/dstack/_internal/server/services/fleets.py
index 588f34698d..19e4a77e64 100644
--- a/src/dstack/_internal/server/services/fleets.py
+++ b/src/dstack/_internal/server/services/fleets.py
@@ -180,9 +180,7 @@ async def list_fleets(
limit=limit,
ascending=ascending,
)
- return [
- fleet_model_to_fleet(v, include_deleted_instances=not only_active) for v in fleet_models
- ]
+ return [fleet_model_to_fleet(v) for v in fleet_models]
async def list_projects_fleet_models(
@@ -227,7 +225,7 @@ async def list_projects_fleet_models(
.where(*filters)
.order_by(*order_by)
.limit(limit)
- .options(joinedload(FleetModel.instances))
+ .options(joinedload(FleetModel.instances.and_(InstanceModel.deleted == False)))
)
fleet_models = list(res.unique().scalars().all())
return fleet_models
@@ -256,7 +254,9 @@ async def list_project_fleet_models(
if not include_deleted:
filters.append(FleetModel.deleted == False)
res = await session.execute(
- select(FleetModel).where(*filters).options(joinedload(FleetModel.instances))
+ select(FleetModel)
+ .where(*filters)
+ .options(joinedload(FleetModel.instances.and_(InstanceModel.deleted == False)))
)
return list(res.unique().scalars().all())
@@ -293,7 +293,9 @@ async def get_project_fleet_model_by_id(
FleetModel.project_id == project.id,
]
res = await session.execute(
- select(FleetModel).where(*filters).options(joinedload(FleetModel.instances))
+ select(FleetModel)
+ .where(*filters)
+ .options(joinedload(FleetModel.instances.and_(InstanceModel.deleted == False)))
)
return res.unique().scalar_one_or_none()
@@ -311,7 +313,9 @@ async def get_project_fleet_model_by_name(
if not include_deleted:
filters.append(FleetModel.deleted == False)
res = await session.execute(
- select(FleetModel).where(*filters).options(joinedload(FleetModel.instances))
+ select(FleetModel)
+ .where(*filters)
+ .options(joinedload(FleetModel.instances.and_(InstanceModel.deleted == False)))
)
return res.unique().scalar_one_or_none()
@@ -717,8 +721,13 @@ def get_fleet_spec(fleet_model: FleetModel) -> FleetSpec:
async def generate_fleet_name(session: AsyncSession, project: ProjectModel) -> str:
- fleet_models = await list_project_fleet_models(session=session, project=project)
- names = {v.name for v in fleet_models}
+ res = await session.execute(
+ select(FleetModel.name).where(
+ FleetModel.project_id == project.id,
+ FleetModel.deleted == False,
+ )
+ )
+ names = set(res.scalars().all())
while True:
name = random_names.generate_name()
if name not in names:
diff --git a/src/dstack/_internal/server/services/volumes.py b/src/dstack/_internal/server/services/volumes.py
index fa3471192d..eb8f4bab64 100644
--- a/src/dstack/_internal/server/services/volumes.py
+++ b/src/dstack/_internal/server/services/volumes.py
@@ -380,8 +380,13 @@ def instance_model_to_volume_instance(instance_model: InstanceModel) -> VolumeIn
async def generate_volume_name(session: AsyncSession, project: ProjectModel) -> str:
- volume_models = await list_project_volume_models(session=session, project=project)
- names = {v.name for v in volume_models}
+ res = await session.execute(
+ select(VolumeModel.name).where(
+ VolumeModel.project_id == project.id,
+ VolumeModel.deleted == False,
+ )
+ )
+ names = set(res.scalars().all())
while True:
name = random_names.generate_name()
if name not in names:
From 811643f5fa72916bef49faec9cb4e0ca61968863 Mon Sep 17 00:00:00 2001
From: Alexander <4584443+DragonStuff@users.noreply.github.com>
Date: Tue, 20 Jan 2026 16:09:44 +0900
Subject: [PATCH 003/640] feat(logging): add fluent-bit log shipping (#3431)
* feat(logging): add fluent-bit log shipping
Implements #3430.
This PR is partially implemented using Cursor.
* Fix pyright errors by using try/except/else pattern for optional imports
* refactor(fluentbit): cleanup protocol lambdas and address codex comments
* feat(fluentbit): validate next_token format and raise ServerClientError for malformed tokens
* chore(fluentbit): address quick comments
* feat(fluentbit): add tag prefix support to HTTPFluentBitWriter
---
docs/docs/guides/server-deployment.md | 80 ++-
docs/docs/reference/environment-variables.md | 7 +
pyproject.toml | 7 +-
.../server/services/logs/__init__.py | 24 +
.../server/services/logs/fluentbit.py | 338 +++++++++
src/dstack/_internal/server/settings.py | 9 +
.../server/services/test_fluentbit_logs.py | 659 ++++++++++++++++++
7 files changed, 1120 insertions(+), 4 deletions(-)
create mode 100644 src/dstack/_internal/server/services/logs/fluentbit.py
create mode 100644 src/tests/_internal/server/services/test_fluentbit_logs.py
diff --git a/docs/docs/guides/server-deployment.md b/docs/docs/guides/server-deployment.md
index f1d7546d77..dc5093f2f2 100644
--- a/docs/docs/guides/server-deployment.md
+++ b/docs/docs/guides/server-deployment.md
@@ -159,7 +159,7 @@ $ DSTACK_DATABASE_URL=postgresql+asyncpg://user:password@db-host:5432/dstack dst
By default, `dstack` stores workload logs locally in `~/.dstack/server/projects//logs`.
For multi-replica server deployments, it's required to store logs externally.
-`dstack` supports storing logs using AWS CloudWatch or GCP Logging.
+`dstack` supports storing logs using AWS CloudWatch, GCP Logging, or Fluent-bit with Elasticsearch / Opensearch.
### AWS CloudWatch
@@ -222,6 +222,78 @@ To store logs using GCP Logging, set the `DSTACK_SERVER_GCP_LOGGING_PROJECT` env
+### Fluent-bit
+
+To store logs using Fluent-bit, set the `DSTACK_SERVER_FLUENTBIT_HOST` environment variable.
+Fluent-bit supports two modes depending on how you want to access logs.
+
+=== "Full mode"
+
+ Logs are shipped to Fluent-bit and can be read back through the dstack UI and CLI via Elasticsearch or OpenSearch.
+ Use this mode when you want a complete integration with log viewing in dstack:
+
+ ```shell
+ $ DSTACK_SERVER_FLUENTBIT_HOST=fluentbit.example.com \
+ DSTACK_SERVER_ELASTICSEARCH_HOST=https://elasticsearch.example.com:9200 \
+ dstack server
+ ```
+
+=== "Ship-only mode"
+
+ Logs are forwarded to Fluent-bit but cannot be read through `dstack`.
+ The dstack UI/CLI will show empty logs. Use this mode when:
+
+ - You have an existing logging infrastructure (Kibana, Grafana, Datadog, etc.)
+ - You only need to forward logs without reading them back through dstack
+ - You want to reduce operational complexity by not running Elasticsearch/OpenSearch
+
+ ```shell
+ $ DSTACK_SERVER_FLUENTBIT_HOST=fluentbit.example.com \
+ dstack server
+ ```
+
+??? info "Additional configuration"
+ The following optional environment variables can be used to customize the Fluent-bit integration:
+
+ **Fluent-bit settings:**
+
+ - `DSTACK_SERVER_FLUENTBIT_PORT` – The Fluent-bit port. Defaults to `24224`.
+ - `DSTACK_SERVER_FLUENTBIT_PROTOCOL` – The protocol to use: `forward` or `http`. Defaults to `forward`.
+ - `DSTACK_SERVER_FLUENTBIT_TAG_PREFIX` – The tag prefix for logs. Defaults to `dstack`.
+
+ **Elasticsearch/OpenSearch settings (for full mode only):**
+
+ - `DSTACK_SERVER_ELASTICSEARCH_HOST` – The Elasticsearch/OpenSearch host for reading logs. If not set, runs in ship-only mode.
+ - `DSTACK_SERVER_ELASTICSEARCH_INDEX` – The Elasticsearch/OpenSearch index pattern. Defaults to `dstack-logs`.
+ - `DSTACK_SERVER_ELASTICSEARCH_API_KEY` – The Elasticsearch/OpenSearch API key for authentication.
+
+??? info "Fluent-bit configuration"
+ Configure Fluent-bit to receive logs and forward them to Elasticsearch or OpenSearch. Example configuration:
+
+ ```ini
+ [INPUT]
+ Name forward
+ Listen 0.0.0.0
+ Port 24224
+
+ [OUTPUT]
+ Name es
+ Match dstack.*
+ Host elasticsearch.example.com
+ Port 9200
+ Index dstack-logs
+ Suppress_Type_Name On
+ ```
+
+??? info "Required dependencies"
+ To use Fluent-bit log storage, install the `fluentbit` extras:
+
+ ```shell
+ $ pip install "dstack[all]" -U
+ # or
+ $ pip install "dstack[fluentbit]" -U
+ ```
+
## File storage
When using [files](../concepts/dev-environments.md#files) or [repos](../concepts/dev-environments.md#repos), `dstack` uploads local files and diffs to the server so that you can have access to them within runs. By default, the files are stored in the DB and each upload is limited to 2MB. You can configure an object storage to be used for uploads and increase the default limit by setting the `DSTACK_SERVER_CODE_UPLOAD_LIMIT` environment variable
@@ -426,8 +498,10 @@ If a deployment is stuck due to a deadlock when applying DB migrations, try scal
??? info "Can I run multiple replicas of dstack server?"
- Yes, you can if you configure `dstack` to use [PostgreSQL](#postgresql) and [AWS CloudWatch](#aws-cloudwatch).
+ Yes, you can if you configure `dstack` to use [PostgreSQL](#postgresql) and an external log storage
+ such as [AWS CloudWatch](#aws-cloudwatch), [GCP Logging](#gcp-logging), or [Fluent-bit](#fluent-bit).
??? info "Does dstack server support blue-green or rolling deployments?"
- Yes, it does if you configure `dstack` to use [PostgreSQL](#postgresql) and [AWS CloudWatch](#aws-cloudwatch).
+ Yes, it does if you configure `dstack` to use [PostgreSQL](#postgresql) and an external log storage
+ such as [AWS CloudWatch](#aws-cloudwatch), [GCP Logging](#gcp-logging), or [Fluent-bit](#fluent-bit).
diff --git a/docs/docs/reference/environment-variables.md b/docs/docs/reference/environment-variables.md
index 4575f1b8f8..62ce97cd12 100644
--- a/docs/docs/reference/environment-variables.md
+++ b/docs/docs/reference/environment-variables.md
@@ -113,6 +113,13 @@ For more details on the options below, refer to the [server deployment](../guide
- `DSTACK_SERVER_CLOUDWATCH_LOG_GROUP`{ #DSTACK_SERVER_CLOUDWATCH_LOG_GROUP } – The CloudWatch Logs group for storing workloads logs. If not set, the default file-based log storage is used.
- `DSTACK_SERVER_CLOUDWATCH_LOG_REGION`{ #DSTACK_SERVER_CLOUDWATCH_LOG_REGION } – The CloudWatch Logs region. Defaults to `None`.
- `DSTACK_SERVER_GCP_LOGGING_PROJECT`{ #DSTACK_SERVER_GCP_LOGGING_PROJECT } – The GCP Logging project for storing workloads logs. If not set, the default file-based log storage is used.
+- `DSTACK_SERVER_FLUENTBIT_HOST`{ #DSTACK_SERVER_FLUENTBIT_HOST } – The Fluent-bit host for log forwarding. If set, enables Fluent-bit log storage.
+- `DSTACK_SERVER_FLUENTBIT_PORT`{ #DSTACK_SERVER_FLUENTBIT_PORT } – The Fluent-bit port. Defaults to `24224`.
+- `DSTACK_SERVER_FLUENTBIT_PROTOCOL`{ #DSTACK_SERVER_FLUENTBIT_PROTOCOL } – The protocol to use: `forward` or `http`. Defaults to `forward`.
+- `DSTACK_SERVER_FLUENTBIT_TAG_PREFIX`{ #DSTACK_SERVER_FLUENTBIT_TAG_PREFIX } – The tag prefix for logs. Defaults to `dstack`.
+- `DSTACK_SERVER_ELASTICSEARCH_HOST`{ #DSTACK_SERVER_ELASTICSEARCH_HOST } – The Elasticsearch/OpenSearch host for reading logs back through dstack. Optional; if not set, Fluent-bit runs in ship-only mode (logs are forwarded but not readable through dstack UI/CLI).
+- `DSTACK_SERVER_ELASTICSEARCH_INDEX`{ #DSTACK_SERVER_ELASTICSEARCH_INDEX } – The Elasticsearch/OpenSearch index pattern. Defaults to `dstack-logs`.
+- `DSTACK_SERVER_ELASTICSEARCH_API_KEY`{ #DSTACK_SERVER_ELASTICSEARCH_API_KEY } – The Elasticsearch/OpenSearch API key for authentication.
- `DSTACK_ENABLE_PROMETHEUS_METRICS`{ #DSTACK_ENABLE_PROMETHEUS_METRICS } — Enables Prometheus metrics collection and export.
- `DSTACK_DEFAULT_SERVICE_CLIENT_MAX_BODY_SIZE`{ #DSTACK_DEFAULT_SERVICE_CLIENT_MAX_BODY_SIZE } – Request body size limit for services running with a gateway, in bytes. Defaults to 64 MiB.
- `DSTACK_SERVICE_CLIENT_TIMEOUT`{ #DSTACK_SERVICE_CLIENT_TIMEOUT } – Timeout in seconds for HTTP requests sent from the in-server proxy and gateways to service replicas. Defaults to 60.
diff --git a/pyproject.toml b/pyproject.toml
index a8d635e7c9..2fe97f2cbb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -215,6 +215,11 @@ nebius = [
"nebius>=0.3.4,<0.4; python_version >= '3.10'",
"dstack[server]",
]
+fluentbit = [
+ "fluent-logger>=0.10.0",
+ "elasticsearch>=8.0.0",
+ "dstack[server]",
+]
all = [
- "dstack[gateway,server,aws,azure,gcp,verda,kubernetes,lambda,nebius,oci]",
+ "dstack[gateway,server,aws,azure,gcp,verda,kubernetes,lambda,nebius,oci,fluentbit]",
]
diff --git a/src/dstack/_internal/server/services/logs/__init__.py b/src/dstack/_internal/server/services/logs/__init__.py
index 5b06ff4ad2..1f8565d49c 100644
--- a/src/dstack/_internal/server/services/logs/__init__.py
+++ b/src/dstack/_internal/server/services/logs/__init__.py
@@ -8,6 +8,7 @@
from dstack._internal.server.schemas.logs import PollLogsRequest
from dstack._internal.server.schemas.runner import LogEvent as RunnerLogEvent
from dstack._internal.server.services.logs import aws as aws_logs
+from dstack._internal.server.services.logs import fluentbit as fluentbit_logs
from dstack._internal.server.services.logs import gcp as gcp_logs
from dstack._internal.server.services.logs.base import (
LogStorage,
@@ -57,6 +58,29 @@ def get_log_storage() -> LogStorage:
logger.debug("Using GCP Logs storage")
else:
logger.error("Cannot use GCP Logs storage: GCP deps are not installed")
+ elif settings.SERVER_FLUENTBIT_HOST:
+ if fluentbit_logs.FLUENTBIT_AVAILABLE:
+ try:
+ _log_storage = fluentbit_logs.FluentBitLogStorage(
+ host=settings.SERVER_FLUENTBIT_HOST,
+ port=settings.SERVER_FLUENTBIT_PORT,
+ protocol=settings.SERVER_FLUENTBIT_PROTOCOL,
+ tag_prefix=settings.SERVER_FLUENTBIT_TAG_PREFIX,
+ es_host=settings.SERVER_ELASTICSEARCH_HOST,
+ es_index=settings.SERVER_ELASTICSEARCH_INDEX,
+ es_api_key=settings.SERVER_ELASTICSEARCH_API_KEY,
+ )
+ except LogStorageError as e:
+ logger.error("Failed to initialize Fluent-bit Logs storage: %s", e)
+ except Exception:
+ logger.exception("Got exception when initializing Fluent-bit Logs storage")
+ else:
+ if settings.SERVER_ELASTICSEARCH_HOST:
+ logger.debug("Using Fluent-bit Logs storage with Elasticsearch/OpenSearch")
+ else:
+ logger.debug("Using Fluent-bit Logs storage in ship-only mode")
+ else:
+ logger.error("Cannot use Fluent-bit Logs storage: fluent-logger is not installed")
if _log_storage is None:
_log_storage = FileLogStorage()
logger.debug("Using file-based storage")
diff --git a/src/dstack/_internal/server/services/logs/fluentbit.py b/src/dstack/_internal/server/services/logs/fluentbit.py
new file mode 100644
index 0000000000..b45b2988d5
--- /dev/null
+++ b/src/dstack/_internal/server/services/logs/fluentbit.py
@@ -0,0 +1,338 @@
+from typing import List, Optional, Protocol
+from uuid import UUID
+
+import httpx
+
+from dstack._internal.core.errors import ServerClientError
+from dstack._internal.core.models.logs import (
+ JobSubmissionLogs,
+ LogEvent,
+ LogEventSource,
+ LogProducer,
+)
+from dstack._internal.server.models import ProjectModel
+from dstack._internal.server.schemas.logs import PollLogsRequest
+from dstack._internal.server.schemas.runner import LogEvent as RunnerLogEvent
+from dstack._internal.server.services.logs.base import (
+ LogStorage,
+ LogStorageError,
+ unix_time_ms_to_datetime,
+)
+from dstack._internal.utils.common import batched
+from dstack._internal.utils.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+ELASTICSEARCH_AVAILABLE = True
+try:
+ from elasticsearch import Elasticsearch
+ from elasticsearch.exceptions import ApiError, TransportError
+except ImportError:
+ ELASTICSEARCH_AVAILABLE = False
+else:
+ ElasticsearchError: tuple = (ApiError, TransportError) # type: ignore[misc]
+
+ class ElasticsearchReader:
+ """Reads logs from Elasticsearch or OpenSearch."""
+
+ def __init__(
+ self,
+ host: str,
+ index: str,
+ api_key: Optional[str] = None,
+ ) -> None:
+ if api_key:
+ self._client = Elasticsearch(hosts=[host], api_key=api_key)
+ else:
+ self._client = Elasticsearch(hosts=[host])
+ self._index = index
+ # Verify connection
+ try:
+ self._client.info()
+ except ElasticsearchError as e:
+ raise LogStorageError(f"Failed to connect to Elasticsearch/OpenSearch: {e}") from e
+
+ def read(
+ self,
+ stream_name: str,
+ request: PollLogsRequest,
+ ) -> JobSubmissionLogs:
+ sort_order = "desc" if request.descending else "asc"
+
+ query: dict = {
+ "bool": {
+ "must": [
+ {"term": {"stream.keyword": stream_name}},
+ ]
+ }
+ }
+
+ if request.start_time:
+ query["bool"].setdefault("filter", []).append(
+ {"range": {"@timestamp": {"gt": request.start_time.isoformat()}}}
+ )
+ if request.end_time:
+ query["bool"].setdefault("filter", []).append(
+ {"range": {"@timestamp": {"lt": request.end_time.isoformat()}}}
+ )
+
+ search_params: dict = {
+ "index": self._index,
+ "query": query,
+ "sort": [
+ {"@timestamp": {"order": sort_order}},
+ {"_id": {"order": sort_order}},
+ ],
+ "size": request.limit,
+ }
+
+ if request.next_token:
+ parts = request.next_token.split(":", 1)
+ if len(parts) != 2 or not parts[0] or not parts[1]:
+ raise ServerClientError(
+ f"Invalid next_token: {request.next_token}. "
+ "Must be in format 'timestamp:document_id'."
+ )
+ search_params["search_after"] = [parts[0], parts[1]]
+
+ try:
+ response = self._client.search(**search_params)
+ except ElasticsearchError as e:
+ logger.error("Elasticsearch/OpenSearch search error: %s", e)
+ raise LogStorageError(f"Elasticsearch/OpenSearch error: {e}") from e
+
+ hits = response.get("hits", {}).get("hits", [])
+ logs = []
+ last_sort_values = None
+
+ for hit in hits:
+ source = hit.get("_source", {})
+ timestamp_str = source.get("@timestamp")
+ message = source.get("message", "")
+
+ if timestamp_str:
+ from datetime import datetime
+
+ try:
+ timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
+ except ValueError:
+ continue
+ else:
+ continue
+
+ logs.append(
+ LogEvent(
+ timestamp=timestamp,
+ log_source=LogEventSource.STDOUT,
+ message=message,
+ )
+ )
+
+ sort_values = hit.get("sort")
+ if sort_values and len(sort_values) >= 2:
+ last_sort_values = sort_values
+
+ next_token = None
+ if len(logs) == request.limit and last_sort_values is not None:
+ next_token = f"{last_sort_values[0]}:{last_sort_values[1]}"
+
+ return JobSubmissionLogs(
+ logs=logs,
+ next_token=next_token,
+ )
+
+ def close(self) -> None:
+ self._client.close()
+
+
+FLUENTBIT_AVAILABLE = True
+try:
+ from fluent import sender as fluent_sender
+except ImportError:
+ FLUENTBIT_AVAILABLE = False
+else:
+
+ class FluentBitWriter(Protocol):
+ def write(self, tag: str, records: List[dict]) -> None: ...
+ def close(self) -> None: ...
+
+ class LogReader(Protocol):
+ def read(self, stream_name: str, request: PollLogsRequest) -> JobSubmissionLogs: ...
+ def close(self) -> None: ...
+
+ class HTTPFluentBitWriter:
+ """Writes logs to Fluent-bit via HTTP POST."""
+
+ def __init__(self, host: str, port: int, tag_prefix: str) -> None:
+ self._endpoint = f"http://{host}:{port}"
+ self._client = httpx.Client(timeout=30.0)
+ self._tag_prefix = tag_prefix
+
+ def write(self, tag: str, records: List[dict]) -> None:
+ prefixed_tag = f"{self._tag_prefix}.{tag}" if self._tag_prefix else tag
+ for record in records:
+ try:
+ response = self._client.post(
+ f"{self._endpoint}/{prefixed_tag}",
+ json=record,
+ headers={"Content-Type": "application/json"},
+ )
+ response.raise_for_status()
+ except httpx.HTTPStatusError as e:
+ logger.error(
+ "Fluent-bit HTTP request failed with status %d: %s",
+ e.response.status_code,
+ e.response.text,
+ )
+ raise LogStorageError(
+ f"Fluent-bit HTTP error: status {e.response.status_code}"
+ ) from e
+ except httpx.HTTPError as e:
+ logger.error("Failed to write log to Fluent-bit via HTTP: %s", e)
+ raise LogStorageError(f"Fluent-bit HTTP error: {e}") from e
+
+ def close(self) -> None:
+ self._client.close()
+
+ class ForwardFluentBitWriter:
+ """Writes logs to Fluent-bit using Forward protocol."""
+
+ def __init__(self, host: str, port: int, tag_prefix: str) -> None:
+ self._sender = fluent_sender.FluentSender(tag_prefix, host=host, port=port)
+ self._tag_prefix = tag_prefix
+
+ def write(self, tag: str, records: List[dict]) -> None:
+ for record in records:
+ if not self._sender.emit(tag, record):
+ error = self._sender.last_error
+ logger.error("Failed to write log to Fluent-bit via Forward: %s", error)
+ self._sender.clear_last_error()
+ raise LogStorageError(f"Fluent-bit Forward error: {error}")
+
+ def close(self) -> None:
+ self._sender.close()
+
+ class NullLogReader:
+ """
+ Null reader for ship-only mode (no Elasticsearch/OpenSearch configured).
+
+ Returns empty logs. Useful when logs are shipped to an external system
+ that is accessed directly rather than through dstack.
+ """
+
+ def read(self, stream_name: str, request: PollLogsRequest) -> JobSubmissionLogs:
+ return JobSubmissionLogs(logs=[], next_token=None)
+
+ def close(self) -> None:
+ pass
+
+ class FluentBitLogStorage(LogStorage):
+ """
+ Log storage using Fluent-bit for writing and optionally Elasticsearch/OpenSearch for reading.
+
+ Supports two modes:
+ - Full mode: Writes to Fluent-bit and reads from Elasticsearch/OpenSearch
+ - Ship-only mode: Writes to Fluent-bit only (no reading, returns empty logs)
+ """
+
+ MAX_BATCH_SIZE = 100
+
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ protocol: str,
+ tag_prefix: str,
+ es_host: Optional[str] = None,
+ es_index: str = "dstack-logs",
+ es_api_key: Optional[str] = None,
+ ) -> None:
+ self._tag_prefix = tag_prefix
+
+ if protocol == "http":
+ self._writer: FluentBitWriter = HTTPFluentBitWriter(
+ host=host, port=port, tag_prefix=tag_prefix
+ )
+ elif protocol == "forward":
+ self._writer = ForwardFluentBitWriter(host=host, port=port, tag_prefix=tag_prefix)
+ else:
+ raise LogStorageError(f"Unsupported Fluent-bit protocol: {protocol}")
+
+ self._reader: LogReader
+ if es_host:
+ if not ELASTICSEARCH_AVAILABLE:
+ raise LogStorageError(
+ "Elasticsearch/OpenSearch host configured but elasticsearch package "
+ "is not installed. Install with: pip install elasticsearch"
+ )
+ self._reader = ElasticsearchReader(
+ host=es_host,
+ index=es_index,
+ api_key=es_api_key,
+ )
+ logger.debug(
+ "Fluent-bit log storage initialized with Elasticsearch/OpenSearch reader"
+ )
+ else:
+ self._reader = NullLogReader()
+ logger.info(
+ "Fluent-bit log storage initialized in ship-only mode "
+ "(no Elasticsearch/OpenSearch configured for reading)"
+ )
+
+ def poll_logs(self, project: ProjectModel, request: PollLogsRequest) -> JobSubmissionLogs:
+ producer = LogProducer.RUNNER if request.diagnose else LogProducer.JOB
+ stream_name = self._get_stream_name(
+ project_name=project.name,
+ run_name=request.run_name,
+ job_submission_id=request.job_submission_id,
+ producer=producer,
+ )
+ return self._reader.read(stream_name=stream_name, request=request)
+
+ def write_logs(
+ self,
+ project: ProjectModel,
+ run_name: str,
+ job_submission_id: UUID,
+ runner_logs: List[RunnerLogEvent],
+ job_logs: List[RunnerLogEvent],
+ ) -> None:
+ producers_with_logs = [(LogProducer.RUNNER, runner_logs), (LogProducer.JOB, job_logs)]
+ for producer, producer_logs in producers_with_logs:
+ if not producer_logs:
+ continue
+ stream_name = self._get_stream_name(
+ project_name=project.name,
+ run_name=run_name,
+ job_submission_id=job_submission_id,
+ producer=producer,
+ )
+ self._write_logs_to_stream(stream_name=stream_name, logs=producer_logs)
+
+ def _write_logs_to_stream(self, stream_name: str, logs: List[RunnerLogEvent]) -> None:
+ for batch in batched(logs, self.MAX_BATCH_SIZE):
+ records = []
+ for log in batch:
+ message = log.message.decode(errors="replace")
+ timestamp = unix_time_ms_to_datetime(log.timestamp)
+ records.append(
+ {
+ "message": message,
+ "@timestamp": timestamp.isoformat(),
+ "stream": stream_name,
+ }
+ )
+ self._writer.write(tag=stream_name, records=records)
+
+ def close(self) -> None:
+ try:
+ self._writer.close()
+ finally:
+ self._reader.close()
+
+ def _get_stream_name(
+ self, project_name: str, run_name: str, job_submission_id: UUID, producer: LogProducer
+ ) -> str:
+ return f"{project_name}/{run_name}/{job_submission_id}/{producer.value}"
diff --git a/src/dstack/_internal/server/settings.py b/src/dstack/_internal/server/settings.py
index 74d1d7b8d5..6e5c8e4bc1 100644
--- a/src/dstack/_internal/server/settings.py
+++ b/src/dstack/_internal/server/settings.py
@@ -78,6 +78,15 @@
SERVER_GCP_LOGGING_PROJECT = os.getenv("DSTACK_SERVER_GCP_LOGGING_PROJECT")
+SERVER_FLUENTBIT_HOST = os.getenv("DSTACK_SERVER_FLUENTBIT_HOST")
+SERVER_FLUENTBIT_PORT = int(os.getenv("DSTACK_SERVER_FLUENTBIT_PORT", "24224"))
+SERVER_FLUENTBIT_PROTOCOL = os.getenv("DSTACK_SERVER_FLUENTBIT_PROTOCOL", "forward")
+SERVER_FLUENTBIT_TAG_PREFIX = os.getenv("DSTACK_SERVER_FLUENTBIT_TAG_PREFIX", "dstack")
+
+SERVER_ELASTICSEARCH_HOST = os.getenv("DSTACK_SERVER_ELASTICSEARCH_HOST")
+SERVER_ELASTICSEARCH_INDEX = os.getenv("DSTACK_SERVER_ELASTICSEARCH_INDEX", "dstack-logs")
+SERVER_ELASTICSEARCH_API_KEY = os.getenv("DSTACK_SERVER_ELASTICSEARCH_API_KEY")
+
SERVER_METRICS_RUNNING_TTL_SECONDS = environ.get_int(
"DSTACK_SERVER_METRICS_RUNNING_TTL_SECONDS", default=3600
)
diff --git a/src/tests/_internal/server/services/test_fluentbit_logs.py b/src/tests/_internal/server/services/test_fluentbit_logs.py
new file mode 100644
index 0000000000..937838e016
--- /dev/null
+++ b/src/tests/_internal/server/services/test_fluentbit_logs.py
@@ -0,0 +1,659 @@
+from datetime import datetime, timezone
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from dstack._internal.core.errors import ServerClientError
+from dstack._internal.server.models import ProjectModel
+from dstack._internal.server.schemas.logs import PollLogsRequest
+from dstack._internal.server.schemas.runner import LogEvent as RunnerLogEvent
+from dstack._internal.server.services.logs.base import LogStorageError
+from dstack._internal.server.services.logs.fluentbit import (
+ ELASTICSEARCH_AVAILABLE,
+ FLUENTBIT_AVAILABLE,
+)
+from dstack._internal.server.testing.common import create_project
+
+pytestmark = pytest.mark.skipif(not FLUENTBIT_AVAILABLE, reason="fluent-logger not installed")
+
+# Conditionally import classes that are only defined when FLUENTBIT_AVAILABLE is True
+if FLUENTBIT_AVAILABLE:
+ from dstack._internal.server.services.logs.fluentbit import (
+ FluentBitLogStorage,
+ ForwardFluentBitWriter,
+ HTTPFluentBitWriter,
+ NullLogReader,
+ )
+
+ if ELASTICSEARCH_AVAILABLE:
+ from dstack._internal.server.services.logs.fluentbit import ElasticsearchReader
+
+
+class TestNullLogReader:
+ """Tests for the NullLogReader (ship-only mode)."""
+
+ def test_read_returns_empty_logs(self):
+ reader = NullLogReader()
+ request = PollLogsRequest(
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ limit=100,
+ )
+ result = reader.read("test-stream", request)
+
+ assert result.logs == []
+ assert result.next_token is None
+
+ def test_close_does_nothing(self):
+ reader = NullLogReader()
+ reader.close() # Should not raise
+
+
+class TestHTTPFluentBitWriter:
+ """Tests for the HTTPFluentBitWriter."""
+
+ @pytest.fixture
+ def mock_httpx_client(self):
+ with patch("dstack._internal.server.services.logs.fluentbit.httpx.Client") as mock:
+ yield mock.return_value
+
+ def test_init_creates_client(self, mock_httpx_client):
+ writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="dstack")
+ assert writer._endpoint == "http://localhost:8080"
+ assert writer._tag_prefix == "dstack"
+
+ def test_write_posts_records(self, mock_httpx_client):
+ writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="dstack")
+ records = [
+ {"message": "Hello", "@timestamp": "2023-10-06T10:00:00+00:00"},
+ {"message": "World", "@timestamp": "2023-10-06T10:00:01+00:00"},
+ ]
+ writer.write(tag="test-tag", records=records)
+
+ assert mock_httpx_client.post.call_count == 2
+ mock_httpx_client.post.assert_any_call(
+ "http://localhost:8080/dstack.test-tag",
+ json=records[0],
+ headers={"Content-Type": "application/json"},
+ )
+ mock_httpx_client.post.assert_any_call(
+ "http://localhost:8080/dstack.test-tag",
+ json=records[1],
+ headers={"Content-Type": "application/json"},
+ )
+
+ def test_write_calls_raise_for_status(self, mock_httpx_client):
+ """Test that response.raise_for_status() is called to detect non-2xx responses."""
+ mock_response = Mock()
+ mock_httpx_client.post.return_value = mock_response
+ writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="dstack")
+
+ writer.write(tag="test-tag", records=[{"message": "test"}])
+
+ mock_response.raise_for_status.assert_called_once()
+
+ def test_write_raises_on_http_status_error(self, mock_httpx_client):
+ """Test that 4xx/5xx responses are properly detected and raise LogStorageError."""
+ import httpx
+
+ mock_response = Mock()
+ mock_response.status_code = 500
+ mock_response.text = "Internal Server Error"
+ mock_httpx_client.post.return_value = mock_response
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
+ "Server Error", request=Mock(), response=mock_response
+ )
+ writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="dstack")
+
+ with pytest.raises(LogStorageError, match="Fluent-bit HTTP error: status 500"):
+ writer.write(tag="test-tag", records=[{"message": "test"}])
+
+ def test_write_raises_on_transport_error(self, mock_httpx_client):
+ import httpx
+
+ mock_httpx_client.post.side_effect = httpx.HTTPError("Connection failed")
+ writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="dstack")
+
+ with pytest.raises(LogStorageError, match="Fluent-bit HTTP error"):
+ writer.write(tag="test-tag", records=[{"message": "test"}])
+
+ def test_close_closes_client(self, mock_httpx_client):
+ writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="dstack")
+ writer.close()
+ mock_httpx_client.close.assert_called_once()
+
+ def test_write_applies_tag_prefix(self, mock_httpx_client):
+ """Test that tag prefix is applied to tags in HTTP requests."""
+ writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="dstack")
+ records = [{"message": "test"}]
+ writer.write(tag="project/run/job", records=records)
+
+ mock_httpx_client.post.assert_called_once_with(
+ "http://localhost:8080/dstack.project/run/job",
+ json=records[0],
+ headers={"Content-Type": "application/json"},
+ )
+
+ def test_write_with_empty_tag_prefix(self, mock_httpx_client):
+ """Test that empty tag prefix doesn't break the tag."""
+ writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="")
+ records = [{"message": "test"}]
+ writer.write(tag="test-tag", records=records)
+
+ mock_httpx_client.post.assert_called_once_with(
+ "http://localhost:8080/test-tag",
+ json=records[0],
+ headers={"Content-Type": "application/json"},
+ )
+
+
+class TestForwardFluentBitWriter:
+ """Tests for the ForwardFluentBitWriter."""
+
+ @pytest.fixture
+ def mock_fluent_sender(self):
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.fluent_sender.FluentSender"
+ ) as mock:
+ mock_instance = Mock()
+ mock_instance.emit.return_value = True
+ mock.return_value = mock_instance
+ yield mock_instance
+
+ def test_init_creates_sender(self, mock_fluent_sender):
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.fluent_sender.FluentSender"
+ ) as mock:
+ mock.return_value = mock_fluent_sender
+ ForwardFluentBitWriter(host="localhost", port=24224, tag_prefix="dstack")
+ mock.assert_called_once_with("dstack", host="localhost", port=24224)
+
+ def test_write_emits_records(self, mock_fluent_sender):
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.fluent_sender.FluentSender"
+ ) as mock:
+ mock.return_value = mock_fluent_sender
+ writer = ForwardFluentBitWriter(host="localhost", port=24224, tag_prefix="dstack")
+
+ records = [
+ {"message": "Hello"},
+ {"message": "World"},
+ ]
+ writer.write(tag="test-tag", records=records)
+
+ assert mock_fluent_sender.emit.call_count == 2
+
+ def test_write_raises_on_emit_failure(self, mock_fluent_sender):
+ mock_fluent_sender.emit.return_value = False
+ mock_fluent_sender.last_error = Exception("Connection refused")
+
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.fluent_sender.FluentSender"
+ ) as mock:
+ mock.return_value = mock_fluent_sender
+ writer = ForwardFluentBitWriter(host="localhost", port=24224, tag_prefix="dstack")
+
+ with pytest.raises(LogStorageError, match="Fluent-bit Forward error"):
+ writer.write(tag="test-tag", records=[{"message": "test"}])
+
+ mock_fluent_sender.clear_last_error.assert_called_once()
+
+ def test_close_closes_sender(self, mock_fluent_sender):
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.fluent_sender.FluentSender"
+ ) as mock:
+ mock.return_value = mock_fluent_sender
+ writer = ForwardFluentBitWriter(host="localhost", port=24224, tag_prefix="dstack")
+ writer.close()
+ mock_fluent_sender.close.assert_called_once()
+
+
+class TestFluentBitLogStorage:
+ """Tests for the FluentBitLogStorage."""
+
+ @pytest_asyncio.fixture
+ async def project(self, test_db, session: AsyncSession) -> ProjectModel:
+ project = await create_project(session=session, name="test-proj")
+ return project
+
+ @pytest.fixture
+ def mock_forward_writer(self):
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as mock:
+ mock_instance = Mock()
+ mock.return_value = mock_instance
+ yield mock_instance
+
+ @pytest.fixture
+ def mock_http_writer(self):
+ with patch("dstack._internal.server.services.logs.fluentbit.HTTPFluentBitWriter") as mock:
+ mock_instance = Mock()
+ mock.return_value = mock_instance
+ yield mock_instance
+
+ @pytest.fixture
+ def mock_es_reader(self):
+ with patch("dstack._internal.server.services.logs.fluentbit.ElasticsearchReader") as mock:
+ mock_instance = Mock()
+ mock.return_value = mock_instance
+ yield mock_instance
+
+ def test_init_with_forward_protocol(self, mock_forward_writer):
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as mock:
+ mock.return_value = mock_forward_writer
+ storage = FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ )
+ mock.assert_called_once_with(host="localhost", port=24224, tag_prefix="dstack")
+ assert isinstance(storage._reader, NullLogReader)
+
+ def test_init_with_http_protocol(self, mock_http_writer):
+ with patch("dstack._internal.server.services.logs.fluentbit.HTTPFluentBitWriter") as mock:
+ mock.return_value = mock_http_writer
+ FluentBitLogStorage(
+ host="localhost",
+ port=8080,
+ protocol="http",
+ tag_prefix="dstack",
+ )
+ mock.assert_called_once_with(host="localhost", port=8080, tag_prefix="dstack")
+
+ def test_init_with_unsupported_protocol_raises(self):
+ with pytest.raises(LogStorageError, match="Unsupported Fluent-bit protocol"):
+ FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="grpc",
+ tag_prefix="dstack",
+ )
+
+ def test_init_ship_only_mode(self, mock_forward_writer):
+ """Test initialization without Elasticsearch (ship-only mode)."""
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as mock:
+ mock.return_value = mock_forward_writer
+ storage = FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ es_host=None,
+ )
+ assert isinstance(storage._reader, NullLogReader)
+
+ @pytest.mark.skipif(not ELASTICSEARCH_AVAILABLE, reason="elasticsearch not installed")
+ def test_init_with_elasticsearch(self, mock_forward_writer, mock_es_reader):
+ """Test initialization with Elasticsearch configured."""
+ with (
+ patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as writer_mock,
+ patch(
+ "dstack._internal.server.services.logs.fluentbit.ElasticsearchReader"
+ ) as reader_mock,
+ ):
+ writer_mock.return_value = mock_forward_writer
+ reader_mock.return_value = mock_es_reader
+
+ FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ es_host="http://elasticsearch:9200",
+ es_index="dstack-logs",
+ es_api_key="test-key",
+ )
+ reader_mock.assert_called_once_with(
+ host="http://elasticsearch:9200",
+ index="dstack-logs",
+ api_key="test-key",
+ )
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_write_logs(self, test_db, project: ProjectModel, mock_forward_writer):
+ """Test writing logs to Fluent-bit."""
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as mock:
+ mock.return_value = mock_forward_writer
+ storage = FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ )
+
+ storage.write_logs(
+ project=project,
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ runner_logs=[
+ RunnerLogEvent(timestamp=1696586513234, message=b"Runner log"),
+ ],
+ job_logs=[
+ RunnerLogEvent(timestamp=1696586513235, message=b"Job log"),
+ ],
+ )
+
+ assert mock_forward_writer.write.call_count == 2
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_write_logs_empty_logs_not_written(
+ self, test_db, project: ProjectModel, mock_forward_writer
+ ):
+ """Test that empty log lists are not written."""
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as mock:
+ mock.return_value = mock_forward_writer
+ storage = FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ )
+
+ storage.write_logs(
+ project=project,
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ runner_logs=[],
+ job_logs=[],
+ )
+
+ mock_forward_writer.write.assert_not_called()
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_poll_logs_ship_only_mode(self, test_db, project: ProjectModel):
+ """Test that ship-only mode returns empty logs."""
+ with patch("dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"):
+ storage = FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ )
+
+ request = PollLogsRequest(
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ limit=100,
+ )
+ result = storage.poll_logs(project, request)
+
+ assert result.logs == []
+ assert result.next_token is None
+
+ def test_close_closes_writer_and_reader(self, mock_forward_writer):
+ """Test that close() closes both writer and reader."""
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as mock:
+ mock.return_value = mock_forward_writer
+ storage = FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ )
+
+ storage.close()
+
+ mock_forward_writer.close.assert_called_once()
+
+ def test_close_closes_reader_even_if_writer_fails(self, mock_forward_writer):
+ """Test that reader is closed even if writer.close() raises an exception."""
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as mock:
+ mock_forward_writer.close.side_effect = Exception("Writer close failed")
+ mock.return_value = mock_forward_writer
+ storage = FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ )
+ mock_reader = Mock()
+ storage._reader = mock_reader
+
+ with pytest.raises(Exception, match="Writer close failed"):
+ storage.close()
+
+ mock_reader.close.assert_called_once()
+
+ def test_get_stream_name(self, mock_forward_writer):
+ """Test stream name generation."""
+ from dstack._internal.core.models.logs import LogProducer
+
+ with patch(
+ "dstack._internal.server.services.logs.fluentbit.ForwardFluentBitWriter"
+ ) as mock:
+ mock.return_value = mock_forward_writer
+ storage = FluentBitLogStorage(
+ host="localhost",
+ port=24224,
+ protocol="forward",
+ tag_prefix="dstack",
+ )
+
+ stream_name = storage._get_stream_name(
+ project_name="my-project",
+ run_name="my-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ producer=LogProducer.JOB,
+ )
+
+ assert stream_name == "my-project/my-run/1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e/job"
+
+
+@pytest.mark.skipif(
+ not FLUENTBIT_AVAILABLE or not ELASTICSEARCH_AVAILABLE,
+ reason="fluent-logger or elasticsearch not installed",
+)
+class TestElasticsearchReader:
+ """Tests for the ElasticsearchReader."""
+
+ @pytest.fixture
+ def mock_es_client(self):
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock_instance = Mock()
+ mock_instance.info.return_value = {"version": {"number": "8.0.0"}}
+ mock_instance.search.return_value = {"hits": {"hits": []}}
+ mock.return_value = mock_instance
+ yield mock_instance
+
+ def test_init_verifies_connection(self, mock_es_client):
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock.return_value = mock_es_client
+ ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ )
+ mock_es_client.info.assert_called_once()
+
+ def test_init_with_api_key(self, mock_es_client):
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock.return_value = mock_es_client
+ ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ api_key="test-api-key",
+ )
+ mock.assert_called_once_with(hosts=["http://localhost:9200"], api_key="test-api-key")
+
+ def test_init_connection_error_raises(self):
+ from elasticsearch.exceptions import ConnectionError as ESConnectionError
+
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock_instance = Mock()
+ mock_instance.info.side_effect = ESConnectionError("Connection refused")
+ mock.return_value = mock_instance
+
+ with pytest.raises(LogStorageError, match="Failed to connect"):
+ ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ )
+
+ def test_read_returns_logs(self, mock_es_client):
+ mock_es_client.search.return_value = {
+ "hits": {
+ "hits": [
+ {
+ "_source": {
+ "@timestamp": "2023-10-06T10:01:53.234000+00:00",
+ "message": "Hello",
+ "stream": "test-stream",
+ },
+ "sort": [1696586513234, "doc1"],
+ },
+ {
+ "_source": {
+ "@timestamp": "2023-10-06T10:01:53.235000+00:00",
+ "message": "World",
+ "stream": "test-stream",
+ },
+ "sort": [1696586513235, "doc2"],
+ },
+ ]
+ }
+ }
+
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock.return_value = mock_es_client
+ reader = ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ )
+
+ request = PollLogsRequest(
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ limit=2,
+ )
+ result = reader.read("test-stream", request)
+
+ assert len(result.logs) == 2
+ assert result.logs[0].message == "Hello"
+ assert result.logs[1].message == "World"
+ assert result.next_token == "1696586513235:doc2"
+
+ def test_read_with_time_filtering(self, mock_es_client):
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock.return_value = mock_es_client
+ reader = ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ )
+
+ request = PollLogsRequest(
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ start_time=datetime(2023, 10, 6, 10, 0, 0, tzinfo=timezone.utc),
+ end_time=datetime(2023, 10, 6, 11, 0, 0, tzinfo=timezone.utc),
+ limit=100,
+ )
+ reader.read("test-stream", request)
+
+ call_args = mock_es_client.search.call_args
+ query = call_args.kwargs["query"]
+ assert "filter" in query["bool"]
+ assert len(query["bool"]["filter"]) == 2
+
+ def test_read_descending_order(self, mock_es_client):
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock.return_value = mock_es_client
+ reader = ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ )
+
+ request = PollLogsRequest(
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ limit=100,
+ descending=True,
+ )
+ reader.read("test-stream", request)
+
+ call_args = mock_es_client.search.call_args
+ assert call_args.kwargs["sort"] == [
+ {"@timestamp": {"order": "desc"}},
+ {"_id": {"order": "desc"}},
+ ]
+
+ def test_read_with_next_token(self, mock_es_client):
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock.return_value = mock_es_client
+ reader = ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ )
+
+ request = PollLogsRequest(
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ next_token="1696586513234:doc1",
+ limit=100,
+ )
+ reader.read("test-stream", request)
+
+ call_args = mock_es_client.search.call_args
+ assert call_args.kwargs["search_after"] == ["1696586513234", "doc1"]
+
+ def test_read_with_malformed_next_token_raises_client_error(self, mock_es_client):
+ """Test that malformed next_token raises ServerClientError (400) instead of IndexError (500)."""
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock.return_value = mock_es_client
+ reader = ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ )
+
+ request = PollLogsRequest(
+ run_name="test-run",
+ job_submission_id=UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e"),
+ next_token="invalid_token_no_colon",
+ limit=100,
+ )
+ with pytest.raises(ServerClientError, match="Invalid next_token"):
+ reader.read("test-stream", request)
+
+ request.next_token = ":"
+ with pytest.raises(ServerClientError, match="Invalid next_token"):
+ reader.read("test-stream", request)
+
+ request.next_token = ":doc1"
+ with pytest.raises(ServerClientError, match="Invalid next_token"):
+ reader.read("test-stream", request)
+
+ request.next_token = "1696586513234:"
+ with pytest.raises(ServerClientError, match="Invalid next_token"):
+ reader.read("test-stream", request)
+
+ mock_es_client.search.assert_not_called()
+
+ def test_close_closes_client(self, mock_es_client):
+ with patch("dstack._internal.server.services.logs.fluentbit.Elasticsearch") as mock:
+ mock.return_value = mock_es_client
+ reader = ElasticsearchReader(
+ host="http://localhost:9200",
+ index="dstack-logs",
+ )
+ reader.close()
+ mock_es_client.close.assert_called_once()
From 29076ba178c97096b88ac07159b4f96171931453 Mon Sep 17 00:00:00 2001
From: Victor Skvortsov
Date: Tue, 20 Jan 2026 13:07:22 +0500
Subject: [PATCH 004/640] Adjust fluent-bit logging integration (#3478)
* Move import to the top
* Fix double error logging
* Add missing backticks
---
docs/docs/guides/server-deployment.md | 6 +++---
.../_internal/server/services/logs/__init__.py | 11 ++++++++---
.../_internal/server/services/logs/fluentbit.py | 13 ++-----------
3 files changed, 13 insertions(+), 17 deletions(-)
diff --git a/docs/docs/guides/server-deployment.md b/docs/docs/guides/server-deployment.md
index dc5093f2f2..42365452aa 100644
--- a/docs/docs/guides/server-deployment.md
+++ b/docs/docs/guides/server-deployment.md
@@ -229,8 +229,8 @@ Fluent-bit supports two modes depending on how you want to access logs.
=== "Full mode"
- Logs are shipped to Fluent-bit and can be read back through the dstack UI and CLI via Elasticsearch or OpenSearch.
- Use this mode when you want a complete integration with log viewing in dstack:
+ Logs are shipped to Fluent-bit and can be read back through the `dstack` UI and CLI via Elasticsearch or OpenSearch.
+ Use this mode when you want a complete integration with log viewing in `dstack`:
```shell
$ DSTACK_SERVER_FLUENTBIT_HOST=fluentbit.example.com \
@@ -244,7 +244,7 @@ Fluent-bit supports two modes depending on how you want to access logs.
The dstack UI/CLI will show empty logs. Use this mode when:
- You have an existing logging infrastructure (Kibana, Grafana, Datadog, etc.)
- - You only need to forward logs without reading them back through dstack
+ - You only need to forward logs without reading them back through `dstack`
- You want to reduce operational complexity by not running Elasticsearch/OpenSearch
```shell
diff --git a/src/dstack/_internal/server/services/logs/__init__.py b/src/dstack/_internal/server/services/logs/__init__.py
index 1f8565d49c..bc601688bc 100644
--- a/src/dstack/_internal/server/services/logs/__init__.py
+++ b/src/dstack/_internal/server/services/logs/__init__.py
@@ -2,6 +2,7 @@
from typing import List, Optional
from uuid import UUID
+from dstack._internal.core.errors import ServerClientError
from dstack._internal.core.models.logs import JobSubmissionLogs
from dstack._internal.server import settings
from dstack._internal.server.models import ProjectModel
@@ -105,9 +106,13 @@ def write_logs(
async def poll_logs_async(project: ProjectModel, request: PollLogsRequest) -> JobSubmissionLogs:
- job_submission_logs = await run_async(
- get_log_storage().poll_logs, project=project, request=request
- )
+ try:
+ job_submission_logs = await run_async(
+ get_log_storage().poll_logs, project=project, request=request
+ )
+ except LogStorageError as e:
+ logger.error("Failed to poll logs from log storage: %s", repr(e))
+ raise ServerClientError("Failed to poll logs from log storage")
# Logs are stored in plaintext but transmitted in base64 for API/CLI backward compatibility.
# Old logs stored in base64 are encoded twice for transmission and shown as base64 in CLI/UI.
# We live with that.
diff --git a/src/dstack/_internal/server/services/logs/fluentbit.py b/src/dstack/_internal/server/services/logs/fluentbit.py
index b45b2988d5..bb97e21f09 100644
--- a/src/dstack/_internal/server/services/logs/fluentbit.py
+++ b/src/dstack/_internal/server/services/logs/fluentbit.py
@@ -1,3 +1,4 @@
+from datetime import datetime
from typing import List, Optional, Protocol
from uuid import UUID
@@ -99,7 +100,6 @@ def read(
try:
response = self._client.search(**search_params)
except ElasticsearchError as e:
- logger.error("Elasticsearch/OpenSearch search error: %s", e)
raise LogStorageError(f"Elasticsearch/OpenSearch error: {e}") from e
hits = response.get("hits", {}).get("hits", [])
@@ -112,8 +112,6 @@ def read(
message = source.get("message", "")
if timestamp_str:
- from datetime import datetime
-
try:
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
except ValueError:
@@ -180,16 +178,10 @@ def write(self, tag: str, records: List[dict]) -> None:
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
- logger.error(
- "Fluent-bit HTTP request failed with status %d: %s",
- e.response.status_code,
- e.response.text,
- )
raise LogStorageError(
f"Fluent-bit HTTP error: status {e.response.status_code}"
) from e
except httpx.HTTPError as e:
- logger.error("Failed to write log to Fluent-bit via HTTP: %s", e)
raise LogStorageError(f"Fluent-bit HTTP error: {e}") from e
def close(self) -> None:
@@ -206,7 +198,6 @@ def write(self, tag: str, records: List[dict]) -> None:
for record in records:
if not self._sender.emit(tag, record):
error = self._sender.last_error
- logger.error("Failed to write log to Fluent-bit via Forward: %s", error)
self._sender.clear_last_error()
raise LogStorageError(f"Fluent-bit Forward error: {error}")
@@ -271,7 +262,7 @@ def __init__(
index=es_index,
api_key=es_api_key,
)
- logger.debug(
+ logger.info(
"Fluent-bit log storage initialized with Elasticsearch/OpenSearch reader"
)
else:
From 54d2d0aa09a54e980529e9c4464a0d1d14db48d2 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Tue, 20 Jan 2026 10:03:55 +0000
Subject: [PATCH 005/640] Emit events for instance status changes (#3477)
- Emit an event on every instance status change
- To make events more informative, set termination
reasons whenever terminating instances
- Add `terminated_by_user` termination reason
- Remove redundant logging now covered by events
- Refactor runtime-only status changes that were
not persisted and did not affect logic
- For event readability, only include the busy
blocks count in job assigned/unassigned events,
which is the only place where the count can
change
---
src/dstack/_internal/core/models/instances.py | 1 +
.../tasks/process_compute_groups.py | 9 +-
.../server/background/tasks/process_fleets.py | 11 +-
.../background/tasks/process_instances.py | 208 +++++-------------
.../tasks/process_submitted_jobs.py | 9 +-
src/dstack/_internal/server/models.py | 1 +
.../_internal/server/services/fleets.py | 33 ++-
.../_internal/server/services/instances.py | 56 ++++-
.../server/services/jobs/__init__.py | 27 ++-
.../_internal/server/routers/test_fleets.py | 30 ++-
.../server/services/test_instances.py | 49 ++++-
11 files changed, 214 insertions(+), 220 deletions(-)
diff --git a/src/dstack/_internal/core/models/instances.py b/src/dstack/_internal/core/models/instances.py
index bf1696758d..012916f97e 100644
--- a/src/dstack/_internal/core/models/instances.py
+++ b/src/dstack/_internal/core/models/instances.py
@@ -256,6 +256,7 @@ def finished_statuses(cls) -> List["InstanceStatus"]:
class InstanceTerminationReason(str, Enum):
+ TERMINATED_BY_USER = "terminated_by_user"
IDLE_TIMEOUT = "idle_timeout"
PROVISIONING_TIMEOUT = "provisioning_timeout"
ERROR = "error"
diff --git a/src/dstack/_internal/server/background/tasks/process_compute_groups.py b/src/dstack/_internal/server/background/tasks/process_compute_groups.py
index 5f7b6820a4..6b449efab4 100644
--- a/src/dstack/_internal/server/background/tasks/process_compute_groups.py
+++ b/src/dstack/_internal/server/background/tasks/process_compute_groups.py
@@ -17,6 +17,7 @@
)
from dstack._internal.server.services import backends as backends_services
from dstack._internal.server.services.compute_groups import compute_group_model_to_compute_group
+from dstack._internal.server.services.instances import switch_instance_status
from dstack._internal.server.services.locking import get_locker
from dstack._internal.server.utils import sentry_utils
from dstack._internal.utils.common import get_current_datetime, run_async
@@ -83,12 +84,14 @@ async def _process_compute_group(session: AsyncSession, compute_group_model: Com
)
compute_group_model = res.unique().scalar_one()
if all(i.status == InstanceStatus.TERMINATING for i in compute_group_model.instances):
- await _terminate_compute_group(compute_group_model)
+ await _terminate_compute_group(session, compute_group_model)
compute_group_model.last_processed_at = get_current_datetime()
await session.commit()
-async def _terminate_compute_group(compute_group_model: ComputeGroupModel) -> None:
+async def _terminate_compute_group(
+ session: AsyncSession, compute_group_model: ComputeGroupModel
+) -> None:
if (
compute_group_model.last_termination_retry_at is not None
and _next_termination_retry_at(compute_group_model) > get_current_datetime()
@@ -147,7 +150,7 @@ async def _terminate_compute_group(compute_group_model: ComputeGroupModel) -> No
instance_model.deleted = True
instance_model.deleted_at = get_current_datetime()
instance_model.finished_at = get_current_datetime()
- instance_model.status = InstanceStatus.TERMINATED
+ switch_instance_status(session, instance_model, InstanceStatus.TERMINATED)
logger.info(
"Terminated compute group %s",
compute_group.name,
diff --git a/src/dstack/_internal/server/background/tasks/process_fleets.py b/src/dstack/_internal/server/background/tasks/process_fleets.py
index d369c7d242..50c3dcfe2a 100644
--- a/src/dstack/_internal/server/background/tasks/process_fleets.py
+++ b/src/dstack/_internal/server/background/tasks/process_fleets.py
@@ -26,7 +26,7 @@
is_fleet_in_use,
switch_fleet_status,
)
-from dstack._internal.server.services.instances import format_instance_status_for_event
+from dstack._internal.server.services.instances import switch_instance_status
from dstack._internal.server.services.locking import get_locker
from dstack._internal.server.utils import sentry_utils
from dstack._internal.utils.common import get_current_datetime
@@ -219,15 +219,10 @@ def _maintain_fleet_nodes_in_min_max_range(
if nodes_redundant == 0:
break
if instance.status in [InstanceStatus.IDLE]:
- instance.status = InstanceStatus.TERMINATING
instance.termination_reason = InstanceTerminationReason.MAX_INSTANCES_LIMIT
instance.termination_reason_message = "Fleet has too many instances"
+ switch_instance_status(session, instance, InstanceStatus.TERMINATING)
nodes_redundant -= 1
- logger.info(
- "Terminating instance %s: %s",
- instance.name,
- instance.termination_reason,
- )
return True
nodes_missing = fleet_spec.configuration.nodes.min - active_instances_num
for i in range(nodes_missing):
@@ -243,7 +238,7 @@ def _maintain_fleet_nodes_in_min_max_range(
session,
(
"Instance created to meet target fleet node count."
- f" Status: {format_instance_status_for_event(instance_model)}"
+ f" Status: {instance_model.status.upper()}"
),
actor=events.SystemActor(),
targets=[events.Target.from_model(instance_model)],
diff --git a/src/dstack/_internal/server/background/tasks/process_instances.py b/src/dstack/_internal/server/background/tasks/process_instances.py
index 454d6ee18a..c2bc27ee85 100644
--- a/src/dstack/_internal/server/background/tasks/process_instances.py
+++ b/src/dstack/_internal/server/background/tasks/process_instances.py
@@ -87,6 +87,7 @@
get_instance_requirements,
get_instance_ssh_private_keys,
remove_dangling_tasks_from_instance,
+ switch_instance_status,
)
from dstack._internal.server.services.locking import get_locker
from dstack._internal.server.services.logging import fmt
@@ -242,7 +243,7 @@ async def _process_instance(session: AsyncSession, instance: InstanceModel):
if instance.status == InstanceStatus.PENDING:
if instance.remote_connection_info is not None:
- await _add_remote(instance)
+ await _add_remote(session, instance)
else:
await _create_instance(
session=session,
@@ -253,17 +254,21 @@ async def _process_instance(session: AsyncSession, instance: InstanceModel):
InstanceStatus.IDLE,
InstanceStatus.BUSY,
):
- idle_duration_expired = _check_and_mark_terminating_if_idle_duration_expired(instance)
+ idle_duration_expired = _check_and_mark_terminating_if_idle_duration_expired(
+ session, instance
+ )
if not idle_duration_expired:
await _check_instance(session, instance)
elif instance.status == InstanceStatus.TERMINATING:
- await _terminate(instance)
+ await _terminate(session, instance)
instance.last_processed_at = get_current_datetime()
await session.commit()
-def _check_and_mark_terminating_if_idle_duration_expired(instance: InstanceModel):
+def _check_and_mark_terminating_if_idle_duration_expired(
+ session: AsyncSession, instance: InstanceModel
+):
if not (
instance.status == InstanceStatus.IDLE
and instance.termination_policy == TerminationPolicy.DESTROY_AFTER_IDLE
@@ -282,17 +287,9 @@ def _check_and_mark_terminating_if_idle_duration_expired(instance: InstanceModel
idle_seconds = instance.termination_idle_time
delta = datetime.timedelta(seconds=idle_seconds)
if idle_duration > delta:
- instance.status = InstanceStatus.TERMINATING
instance.termination_reason = InstanceTerminationReason.IDLE_TIMEOUT
- logger.info(
- "Instance %s idle duration expired: idle time %ss. Terminating",
- instance.name,
- str(idle_duration.seconds),
- extra={
- "instance_name": instance.name,
- "instance_status": instance.status.value,
- },
- )
+ instance.termination_reason_message = f"Instance idle for {idle_duration.seconds}s"
+ switch_instance_status(session, instance, InstanceStatus.TERMINATING)
return True
return False
@@ -311,24 +308,16 @@ def _can_terminate_fleet_instances_on_idle_duration(fleet_model: FleetModel) ->
return active_instances_num > fleet.spec.configuration.nodes.min
-async def _add_remote(instance: InstanceModel) -> None:
+async def _add_remote(session: AsyncSession, instance: InstanceModel) -> None:
logger.info("Adding ssh instance %s...", instance.name)
- if instance.status == InstanceStatus.PENDING:
- instance.status = InstanceStatus.PROVISIONING
retry_duration_deadline = instance.created_at + timedelta(seconds=PROVISIONING_TIMEOUT_SECONDS)
if retry_duration_deadline < get_current_datetime():
- instance.status = InstanceStatus.TERMINATED
instance.termination_reason = InstanceTerminationReason.PROVISIONING_TIMEOUT
- logger.warning(
- "Failed to start instance %s in %d seconds. Terminating...",
- instance.name,
- PROVISIONING_TIMEOUT_SECONDS,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATED.value,
- },
+ instance.termination_reason_message = (
+ f"Failed to add SSH instance in {PROVISIONING_TIMEOUT_SECONDS}s"
)
+ switch_instance_status(session, instance, InstanceStatus.TERMINATED)
return
try:
@@ -341,17 +330,9 @@ async def _add_remote(instance: InstanceModel) -> None:
else:
ssh_proxy_pkeys = None
except (ValueError, PasswordRequiredException):
- instance.status = InstanceStatus.TERMINATED
instance.termination_reason = InstanceTerminationReason.ERROR
instance.termination_reason_message = "Unsupported private SSH key type"
- logger.warning(
- "Failed to add instance %s: unsupported private SSH key type",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATED.value,
- },
- )
+ switch_instance_status(session, instance, InstanceStatus.TERMINATED)
return
authorized_keys = [pk.public.strip() for pk in remote_details.ssh_keys]
@@ -368,19 +349,13 @@ async def _add_remote(instance: InstanceModel) -> None:
raise ProvisioningError(f"Deploy timeout: {e}") from e
except Exception as e:
raise ProvisioningError(f"Deploy instance raised an error: {e}") from e
- else:
- logger.info(
- "The instance %s (%s) was successfully added",
- instance.name,
- remote_details.host,
- )
except ProvisioningError as e:
logger.warning(
"Provisioning instance %s could not be completed because of the error: %s",
instance.name,
e,
)
- instance.status = InstanceStatus.PENDING
+ # Stays in PENDING, may retry later
return
instance_type = host_info_to_instance_type(host_info, arch)
@@ -400,35 +375,19 @@ async def _add_remote(instance: InstanceModel) -> None:
addresses=host_network_addresses,
)
if instance_network is not None and internal_ip is None:
- instance.status = InstanceStatus.TERMINATED
instance.termination_reason = InstanceTerminationReason.ERROR
instance.termination_reason_message = (
"Failed to locate internal IP address on the given network"
)
- logger.warning(
- "Failed to add instance %s: failed to locate internal IP address on the given network",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATED.value,
- },
- )
+ switch_instance_status(session, instance, InstanceStatus.TERMINATED)
return
if internal_ip is not None:
if not is_ip_among_addresses(ip_address=internal_ip, addresses=host_network_addresses):
- instance.status = InstanceStatus.TERMINATED
instance.termination_reason = InstanceTerminationReason.ERROR
instance.termination_reason_message = (
"Specified internal IP not found among instance interfaces"
)
- logger.warning(
- "Failed to add instance %s: specified internal IP not found among instance interfaces",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATED.value,
- },
- )
+ switch_instance_status(session, instance, InstanceStatus.TERMINATED)
return
divisible, blocks = is_divisible_into_blocks(
@@ -439,17 +398,9 @@ async def _add_remote(instance: InstanceModel) -> None:
if divisible:
instance.total_blocks = blocks
else:
- instance.status = InstanceStatus.TERMINATED
instance.termination_reason = InstanceTerminationReason.ERROR
instance.termination_reason_message = "Cannot split into blocks"
- logger.warning(
- "Failed to add instance %s: cannot split into blocks",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATED.value,
- },
- )
+ switch_instance_status(session, instance, InstanceStatus.TERMINATED)
return
region = instance.region
@@ -470,7 +421,9 @@ async def _add_remote(instance: InstanceModel) -> None:
ssh_proxy=remote_details.ssh_proxy,
)
- instance.status = InstanceStatus.IDLE if health else InstanceStatus.PROVISIONING
+ switch_instance_status(
+ session, instance, InstanceStatus.IDLE if health else InstanceStatus.PROVISIONING
+ )
instance.backend = BackendType.REMOTE
instance_offer = InstanceOfferWithAvailability(
backend=BackendType.REMOTE,
@@ -562,18 +515,13 @@ async def _create_instance(session: AsyncSession, instance: InstanceModel) -> No
profile = get_instance_profile(instance)
requirements = get_instance_requirements(instance)
except ValidationError as e:
- instance.status = InstanceStatus.TERMINATED
instance.termination_reason = InstanceTerminationReason.ERROR
instance.termination_reason_message = (
f"Error to parse profile, requirements or instance_configuration: {e}"
)
- logger.warning(
- "Error to parse profile, requirements or instance_configuration. Terminate instance: %s",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATED.value,
- },
+ switch_instance_status(session, instance, InstanceStatus.TERMINATED)
+ logger.exception(
+ "%s: error parsing profile, requirements or instance configuration", fmt(instance)
)
return
@@ -664,7 +612,7 @@ async def _create_instance(session: AsyncSession, instance: InstanceModel) -> No
)
continue
- instance.status = InstanceStatus.PROVISIONING
+ switch_instance_status(session, instance, InstanceStatus.PROVISIONING)
instance.backend = backend.TYPE
instance.region = instance_offer.region
instance.price = instance_offer.price
@@ -674,14 +622,6 @@ async def _create_instance(session: AsyncSession, instance: InstanceModel) -> No
instance.total_blocks = instance_offer.total_blocks
instance.started_at = get_current_datetime()
- logger.info(
- "Created instance %s",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.PROVISIONING.value,
- },
- )
if instance.fleet_id and instance.id == master_instance.id:
# Clean up placement groups that did not end up being used.
# Flush to update still uncommitted placement groups.
@@ -695,18 +635,17 @@ async def _create_instance(session: AsyncSession, instance: InstanceModel) -> No
)
return
- _mark_terminated(
- instance,
- InstanceTerminationReason.NO_OFFERS,
- "All offers failed" if offers else "No offers found",
- )
+ instance.termination_reason = InstanceTerminationReason.NO_OFFERS
+ instance.termination_reason_message = "All offers failed" if offers else "No offers found"
+ switch_instance_status(session, instance, InstanceStatus.TERMINATED)
if instance.fleet and instance.id == master_instance.id and is_cloud_cluster(instance.fleet):
# Do not attempt to deploy other instances, as they won't determine the correct cluster
# backend, region, and placement group without a successfully deployed master instance
for sibling_instance in instance.fleet.instances:
if sibling_instance.id == instance.id:
continue
- _mark_terminated(sibling_instance, InstanceTerminationReason.MASTER_FAILED)
+ sibling_instance.termination_reason = InstanceTerminationReason.MASTER_FAILED
+ switch_instance_status(session, sibling_instance, InstanceStatus.TERMINATED)
async def _get_fleet_master_instance(
@@ -723,25 +662,6 @@ async def _get_fleet_master_instance(
return res.scalar_one()
-def _mark_terminated(
- instance: InstanceModel,
- termination_reason: InstanceTerminationReason,
- termination_reason_message: Optional[str] = None,
-) -> None:
- instance.status = InstanceStatus.TERMINATED
- instance.termination_reason = termination_reason
- instance.termination_reason_message = termination_reason_message
- logger.info(
- "Terminated instance %s: %s",
- instance.name,
- instance.termination_reason,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATED.value,
- },
- )
-
-
async def _check_instance(session: AsyncSession, instance: InstanceModel) -> None:
if (
instance.status == InstanceStatus.BUSY
@@ -749,9 +669,9 @@ async def _check_instance(session: AsyncSession, instance: InstanceModel) -> Non
and all(job.status.is_finished() for job in instance.jobs)
):
# A busy instance could have no active jobs due to this bug: https://github.com/dstackai/dstack/issues/2068
- instance.status = InstanceStatus.TERMINATING
instance.termination_reason = InstanceTerminationReason.JOB_FINISHED
- logger.info(
+ switch_instance_status(session, instance, InstanceStatus.TERMINATING)
+ logger.warning(
"Detected busy instance %s with finished job. Marked as TERMINATING",
instance.name,
extra={
@@ -770,6 +690,7 @@ async def _check_instance(session: AsyncSession, instance: InstanceModel) -> Non
)
project = res.unique().scalar_one()
await _wait_for_instance_provisioning_data(
+ session=session,
project=project,
instance=instance,
job_provisioning_data=job_provisioning_data,
@@ -778,7 +699,7 @@ async def _check_instance(session: AsyncSession, instance: InstanceModel) -> Non
if not job_provisioning_data.dockerized:
if instance.status == InstanceStatus.PROVISIONING:
- instance.status = InstanceStatus.BUSY
+ switch_instance_status(session, instance, InstanceStatus.BUSY)
return
ssh_private_keys = get_instance_ssh_private_keys(instance)
@@ -845,15 +766,10 @@ async def _check_instance(session: AsyncSession, instance: InstanceModel) -> Non
instance.termination_deadline = None
if instance.status == InstanceStatus.PROVISIONING:
- instance.status = InstanceStatus.IDLE if not instance.jobs else InstanceStatus.BUSY
- logger.info(
- "Instance %s has switched to %s status",
- instance.name,
- instance.status.value,
- extra={
- "instance_name": instance.name,
- "instance_status": instance.status.value,
- },
+ switch_instance_status(
+ session,
+ instance,
+ InstanceStatus.IDLE if not instance.jobs else InstanceStatus.BUSY,
)
return
@@ -866,31 +782,18 @@ async def _check_instance(session: AsyncSession, instance: InstanceModel) -> Non
job_provisioning_data=job_provisioning_data,
)
if get_current_datetime() > provisioning_deadline:
- instance.status = InstanceStatus.TERMINATING
- logger.warning(
- "Instance %s has not started in time. Marked as TERMINATING",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATING.value,
- },
- )
+ instance.termination_reason = InstanceTerminationReason.PROVISIONING_TIMEOUT
+ instance.termination_reason_message = "Instance did not become reachable in time"
+ switch_instance_status(session, instance, InstanceStatus.TERMINATING)
elif instance.status.is_available():
deadline = instance.termination_deadline
if get_current_datetime() > deadline:
- instance.status = InstanceStatus.TERMINATING
instance.termination_reason = InstanceTerminationReason.UNREACHABLE
- logger.warning(
- "Instance %s shim waiting timeout. Marked as TERMINATING",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATING.value,
- },
- )
+ switch_instance_status(session, instance, InstanceStatus.TERMINATING)
async def _wait_for_instance_provisioning_data(
+ session: AsyncSession,
project: ProjectModel,
instance: InstanceModel,
job_provisioning_data: JobProvisioningData,
@@ -904,12 +807,9 @@ async def _wait_for_instance_provisioning_data(
job_provisioning_data=job_provisioning_data,
)
if get_current_datetime() > provisioning_deadline:
- logger.warning(
- "Instance %s failed because instance has not become running in time", instance.name
- )
- instance.status = InstanceStatus.TERMINATING
instance.termination_reason = InstanceTerminationReason.PROVISIONING_TIMEOUT
instance.termination_reason_message = "Backend did not complete provisioning in time"
+ switch_instance_status(session, instance, InstanceStatus.TERMINATING)
return
backend = await backends_services.get_project_backend_by_type(
@@ -921,9 +821,9 @@ async def _wait_for_instance_provisioning_data(
"Instance %s failed because instance's backend is not available",
instance.name,
)
- instance.status = InstanceStatus.TERMINATING
instance.termination_reason = InstanceTerminationReason.ERROR
instance.termination_reason_message = "Backend not available"
+ switch_instance_status(session, instance, InstanceStatus.TERMINATING)
return
try:
await run_async(
@@ -939,9 +839,9 @@ async def _wait_for_instance_provisioning_data(
instance.name,
repr(e),
)
- instance.status = InstanceStatus.TERMINATING
instance.termination_reason = InstanceTerminationReason.ERROR
instance.termination_reason_message = "Error while waiting for instance to become running"
+ switch_instance_status(session, instance, InstanceStatus.TERMINATING)
except Exception:
logger.exception(
"Got exception when updating instance %s provisioning data", instance.name
@@ -1137,7 +1037,7 @@ def _get_instance_cpu_arch(instance: InstanceModel) -> Optional[gpuhunt.CPUArchi
return jpd.instance_type.resources.cpu_arch
-async def _terminate(instance: InstanceModel) -> None:
+async def _terminate(session: AsyncSession, instance: InstanceModel) -> None:
if (
instance.last_termination_retry_at is not None
and _next_termination_retry_at(instance) > get_current_datetime()
@@ -1190,15 +1090,7 @@ async def _terminate(instance: InstanceModel) -> None:
instance.deleted = True
instance.deleted_at = get_current_datetime()
instance.finished_at = get_current_datetime()
- instance.status = InstanceStatus.TERMINATED
- logger.info(
- "Instance %s terminated",
- instance.name,
- extra={
- "instance_name": instance.name,
- "instance_status": InstanceStatus.TERMINATED.value,
- },
- )
+ switch_instance_status(session, instance, InstanceStatus.TERMINATED)
def _next_termination_retry_at(instance: InstanceModel) -> datetime.datetime:
diff --git a/src/dstack/_internal/server/background/tasks/process_submitted_jobs.py b/src/dstack/_internal/server/background/tasks/process_submitted_jobs.py
index e132f83a49..2320394436 100644
--- a/src/dstack/_internal/server/background/tasks/process_submitted_jobs.py
+++ b/src/dstack/_internal/server/background/tasks/process_submitted_jobs.py
@@ -87,8 +87,9 @@
is_cloud_cluster,
)
from dstack._internal.server.services.instances import (
- format_instance_status_for_event,
+ format_instance_blocks_for_event,
get_instance_provisioning_data,
+ switch_instance_status,
)
from dstack._internal.server.services.jobs import (
check_can_attach_job_volumes,
@@ -507,7 +508,7 @@ async def _process_submitted_job(
session.add(instance)
events.emit(
session,
- f"Instance created for job. Instance status: {format_instance_status_for_event(instance)}",
+ f"Instance created for job. Instance status: {instance.status.upper()}",
actor=events.SystemActor(),
targets=[
events.Target.from_model(instance),
@@ -646,7 +647,7 @@ async def _assign_job_to_fleet_instance(
.options(joinedload(InstanceModel.volume_attachments))
)
instance = res.unique().scalar_one()
- instance.status = InstanceStatus.BUSY
+ switch_instance_status(session, instance, InstanceStatus.BUSY)
instance.busy_blocks += offer.blocks
job_model.instance = instance
@@ -657,7 +658,7 @@ async def _assign_job_to_fleet_instance(
session,
(
"Job assigned to instance."
- f" Instance status: {format_instance_status_for_event(instance)}"
+ f" Instance blocks: {format_instance_blocks_for_event(instance)}"
),
actor=events.SystemActor(),
targets=[
diff --git a/src/dstack/_internal/server/models.py b/src/dstack/_internal/server/models.py
index 5274d9ebfd..6a8aa41eb4 100644
--- a/src/dstack/_internal/server/models.py
+++ b/src/dstack/_internal/server/models.py
@@ -632,6 +632,7 @@ class InstanceModel(BaseModel):
compute_group_id: Mapped[Optional[uuid.UUID]] = mapped_column(ForeignKey("compute_groups.id"))
compute_group: Mapped[Optional["ComputeGroupModel"]] = relationship(back_populates="instances")
+ # NOTE: `status` must be changed only via `switch_instance_status()`
status: Mapped[InstanceStatus] = mapped_column(EnumAsString(InstanceStatus, 100), index=True)
unreachable: Mapped[bool] = mapped_column(Boolean)
diff --git a/src/dstack/_internal/server/services/fleets.py b/src/dstack/_internal/server/services/fleets.py
index 19e4a77e64..9b877475f7 100644
--- a/src/dstack/_internal/server/services/fleets.py
+++ b/src/dstack/_internal/server/services/fleets.py
@@ -31,6 +31,7 @@
from dstack._internal.core.models.instances import (
InstanceOfferWithAvailability,
InstanceStatus,
+ InstanceTerminationReason,
RemoteConnectionInfo,
SSHConnectionParams,
SSHKey,
@@ -65,9 +66,9 @@
from dstack._internal.server.services import instances as instances_services
from dstack._internal.server.services import offers as offers_services
from dstack._internal.server.services.instances import (
- format_instance_status_for_event,
get_instance_remote_connection_info,
list_active_remote_instances,
+ switch_instance_status,
)
from dstack._internal.server.services.locking import (
get_locker,
@@ -679,7 +680,9 @@ async def delete_fleets(
"Deleting fleets %s instances %s", [f.name for f in fleet_models], instance_nums
)
for fleet_model in fleet_models:
- _terminate_fleet_instances(fleet_model=fleet_model, instance_nums=instance_nums)
+ _terminate_fleet_instances(
+ session=session, fleet_model=fleet_model, instance_nums=instance_nums, actor=user
+ )
# TERMINATING fleets are deleted by process_fleets after instances are terminated
if instance_nums is None:
switch_fleet_status(
@@ -873,7 +876,7 @@ async def _create_fleet(
session,
(
"Instance created on fleet submission."
- f" Status: {format_instance_status_for_event(instance_model)}"
+ f" Status: {instance_model.status.upper()}"
),
actor=events.UserActor.from_user(user),
targets=[events.Target.from_model(instance_model)],
@@ -892,7 +895,7 @@ async def _create_fleet(
session,
(
"Instance created on fleet submission."
- f" Status: {format_instance_status_for_event(instance_model)}"
+ f" Status: {instance_model.status.upper()}"
),
# Set `SystemActor` for consistency with other places where cloud instances can be
# created (fleet spec consolidation, job provisioning, etc). Think of the fleet as being
@@ -978,17 +981,14 @@ async def _update_fleet(
)
events.emit(
session,
- (
- "Instance created on fleet update."
- f" Status: {format_instance_status_for_event(instance_model)}"
- ),
+ f"Instance created on fleet update. Status: {instance_model.status.upper()}",
actor=events.UserActor.from_user(user),
targets=[events.Target.from_model(instance_model)],
)
fleet_model.instances.append(instance_model)
active_instance_nums.add(instance_num)
if removed_instance_nums:
- _terminate_fleet_instances(fleet_model, removed_instance_nums)
+ _terminate_fleet_instances(session, fleet_model, removed_instance_nums, actor=user)
await session.commit()
return fleet_model_to_fleet(fleet_model)
@@ -1197,7 +1197,12 @@ def _get_fleet_nodes_to_provision(spec: FleetSpec) -> int:
return spec.configuration.nodes.target
-def _terminate_fleet_instances(fleet_model: FleetModel, instance_nums: Optional[List[int]]):
+def _terminate_fleet_instances(
+ session: AsyncSession,
+ fleet_model: FleetModel,
+ instance_nums: Optional[List[int]],
+ actor: UserModel,
+):
if is_fleet_in_use(fleet_model, instance_nums=instance_nums):
if instance_nums is not None:
raise ServerClientError(
@@ -1210,4 +1215,10 @@ def _terminate_fleet_instances(fleet_model: FleetModel, instance_nums: Optional[
if instance.status == InstanceStatus.TERMINATED:
instance.deleted = True
else:
- instance.status = InstanceStatus.TERMINATING
+ instance.termination_reason = InstanceTerminationReason.TERMINATED_BY_USER
+ switch_instance_status(
+ session,
+ instance,
+ InstanceStatus.TERMINATING,
+ actor=events.UserActor.from_user(actor),
+ )
diff --git a/src/dstack/_internal/server/services/instances.py b/src/dstack/_internal/server/services/instances.py
index bf837469d0..14f26cc3f0 100644
--- a/src/dstack/_internal/server/services/instances.py
+++ b/src/dstack/_internal/server/services/instances.py
@@ -25,6 +25,7 @@
InstanceOffer,
InstanceOfferWithAvailability,
InstanceStatus,
+ InstanceTerminationReason,
InstanceType,
RemoteConnectionInfo,
Resources,
@@ -49,6 +50,7 @@
)
from dstack._internal.server.schemas.health.dcgm import DCGMHealthResponse
from dstack._internal.server.schemas.runner import InstanceHealthResponse, TaskStatus
+from dstack._internal.server.services import events
from dstack._internal.server.services.logging import fmt
from dstack._internal.server.services.offers import generate_shared_offer
from dstack._internal.server.services.projects import list_user_project_models
@@ -59,11 +61,55 @@
logger = get_logger(__name__)
-def format_instance_status_for_event(instance_model: InstanceModel) -> str:
- msg = instance_model.status.upper()
- if instance_model.total_blocks is not None:
- msg += f" ({instance_model.busy_blocks}/{instance_model.total_blocks} blocks busy)"
- return msg
+def switch_instance_status(
+ session: AsyncSession,
+ instance_model: InstanceModel,
+ new_status: InstanceStatus,
+ actor: events.AnyActor = events.SystemActor(),
+):
+ """
+ Switch instance status.
+
+ **Usage notes**:
+
+ - When switching to `TERMINATING` or `TERMINATED`,
+ `instance_model.termination_reason` must be set
+
+ - When `instance_model.termination_reason` is set to `ERROR`,
+ the error must be further explained in `instance_model.termination_reason_message`
+ """
+
+ old_status = instance_model.status
+ if old_status == new_status:
+ return
+
+ instance_model.status = new_status
+
+ msg = f"Instance status changed {old_status.upper()} -> {new_status.upper()}"
+ if (
+ new_status == InstanceStatus.TERMINATING
+ or new_status == InstanceStatus.TERMINATED
+ and old_status != InstanceStatus.TERMINATING
+ ):
+ if instance_model.termination_reason is None:
+ raise ValueError(
+ f"termination_reason must be set when switching to {new_status.upper()} status"
+ )
+ if (
+ instance_model.termination_reason == InstanceTerminationReason.ERROR
+ and not instance_model.termination_reason_message
+ ):
+ raise ValueError(
+ "termination_reason_message must be set when termination_reason is ERROR"
+ )
+ msg += f". Termination reason: {instance_model.termination_reason.upper()}"
+ if instance_model.termination_reason_message:
+ msg += f" ({instance_model.termination_reason_message})"
+ events.emit(session, msg, actor=actor, targets=[events.Target.from_model(instance_model)])
+
+
+def format_instance_blocks_for_event(instance_model: InstanceModel) -> str:
+ return f"{instance_model.busy_blocks}/{instance_model.total_blocks} busy"
async def get_instance_health_checks(
diff --git a/src/dstack/_internal/server/services/jobs/__init__.py b/src/dstack/_internal/server/services/jobs/__init__.py
index b86bd09643..18d410c133 100644
--- a/src/dstack/_internal/server/services/jobs/__init__.py
+++ b/src/dstack/_internal/server/services/jobs/__init__.py
@@ -21,7 +21,7 @@
)
from dstack._internal.core.models.backends.base import BackendType
from dstack._internal.core.models.configurations import RunConfigurationType
-from dstack._internal.core.models.instances import InstanceStatus
+from dstack._internal.core.models.instances import InstanceStatus, InstanceTerminationReason
from dstack._internal.core.models.runs import (
Job,
JobProvisioningData,
@@ -44,8 +44,9 @@
from dstack._internal.server.services import events, services
from dstack._internal.server.services import volumes as volumes_services
from dstack._internal.server.services.instances import (
- format_instance_status_for_event,
+ format_instance_blocks_for_event,
get_instance_ssh_private_keys,
+ switch_instance_status,
)
from dstack._internal.server.services.jobs.configurators.base import (
JobConfigurator,
@@ -352,18 +353,16 @@ async def process_terminating_job(
blocks = 1
instance_model.busy_blocks -= blocks
- if instance_model.status == InstanceStatus.BUSY:
+ if instance_model.status != InstanceStatus.BUSY or jpd is None or not jpd.dockerized:
+ # Terminate instances that:
+ # - have not finished provisioning yet
+ # - belong to container-based backends, and hence cannot be reused
+ if instance_model.status not in InstanceStatus.finished_statuses():
+ instance_model.termination_reason = InstanceTerminationReason.JOB_FINISHED
+ switch_instance_status(session, instance_model, InstanceStatus.TERMINATING)
+ elif not [j for j in instance_model.jobs if j.id != job_model.id]:
# no other jobs besides this one
- if not [j for j in instance_model.jobs if j.id != job_model.id]:
- instance_model.status = InstanceStatus.IDLE
- elif instance_model.status != InstanceStatus.TERMINATED:
- # instance was PROVISIONING (specially for the job)
- # schedule for termination
- instance_model.status = InstanceStatus.TERMINATING
-
- if jpd is None or not jpd.dockerized:
- # do not reuse vastai/k8s instances
- instance_model.status = InstanceStatus.TERMINATING
+ switch_instance_status(session, instance_model, InstanceStatus.IDLE)
# The instance should be released even if detach fails
# so that stuck volumes don't prevent the instance from terminating.
@@ -374,7 +373,7 @@ async def process_terminating_job(
session,
(
"Job unassigned from instance."
- f" Instance status: {format_instance_status_for_event(instance_model)}"
+ f" Instance blocks: {format_instance_blocks_for_event(instance_model)}"
),
actor=events.SystemActor(),
targets=[
diff --git a/src/tests/_internal/server/routers/test_fleets.py b/src/tests/_internal/server/routers/test_fleets.py
index afa68b788d..b00d6ccf57 100644
--- a/src/tests/_internal/server/routers/test_fleets.py
+++ b/src/tests/_internal/server/routers/test_fleets.py
@@ -2,7 +2,7 @@
from datetime import datetime, timezone
from typing import Optional
from unittest.mock import Mock, patch
-from uuid import UUID, uuid4
+from uuid import uuid4
import pytest
from freezegun import freeze_time
@@ -603,19 +603,17 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A
remote_connection_info=get_remote_connection_info(host="10.0.0.100"),
)
- with patch("uuid.uuid4") as m:
- m.return_value = UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e")
- response = await client.post(
- f"/api/project/{project.name}/fleets/apply",
- headers=get_auth_headers(user.token),
- json={
- "plan": {
- "spec": spec.dict(),
- "current_resource": _fleet_model_to_json_dict(fleet),
- },
- "force": False,
+ response = await client.post(
+ f"/api/project/{project.name}/fleets/apply",
+ headers=get_auth_headers(user.token),
+ json={
+ "plan": {
+ "spec": spec.dict(),
+ "current_resource": _fleet_model_to_json_dict(fleet),
},
- )
+ "force": False,
+ },
+ )
assert response.status_code == 200, response.json()
assert response.json() == {
@@ -711,7 +709,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A
"status": "terminating",
"unreachable": False,
"health_status": "healthy",
- "termination_reason": None,
+ "termination_reason": "terminated_by_user",
"termination_reason_message": None,
"created": "2023-01-02T03:04:00+00:00",
"region": "remote",
@@ -721,7 +719,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A
"busy_blocks": 0,
},
{
- "id": "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e",
+ "id": SomeUUID4Str(),
"project_name": project.name,
"backend": "remote",
"instance_type": {
@@ -761,7 +759,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A
await session.refresh(instance)
assert instance.status == InstanceStatus.TERMINATING
res = await session.execute(
- select(InstanceModel).where(InstanceModel.id == "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e")
+ select(InstanceModel).where(InstanceModel.id == response.json()["instances"][1]["id"])
)
instance = res.unique().scalar_one()
assert instance.status == InstanceStatus.PENDING
diff --git a/src/tests/_internal/server/services/test_instances.py b/src/tests/_internal/server/services/test_instances.py
index aa248aa485..9e4cb02e3a 100644
--- a/src/tests/_internal/server/services/test_instances.py
+++ b/src/tests/_internal/server/services/test_instances.py
@@ -1,6 +1,7 @@
import uuid
import pytest
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import dstack._internal.server.services.instances as instances_services
@@ -9,11 +10,12 @@
from dstack._internal.core.models.instances import (
Instance,
InstanceStatus,
+ InstanceTerminationReason,
InstanceType,
Resources,
)
from dstack._internal.core.models.profiles import Profile
-from dstack._internal.server.models import InstanceModel
+from dstack._internal.server.models import EventModel, InstanceModel
from dstack._internal.server.testing.common import (
create_instance,
create_project,
@@ -24,6 +26,51 @@
from dstack._internal.utils.common import get_current_datetime
+class TestSwitchInstanceStatus:
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_includes_termination_reason_in_event_messages_only_once(
+ self, test_db, session: AsyncSession
+ ) -> None:
+ project = await create_project(session=session)
+ instance = await create_instance(
+ session=session, project=project, status=InstanceStatus.PENDING
+ )
+ instance.termination_reason = InstanceTerminationReason.ERROR
+ instance.termination_reason_message = "Some err"
+ instances_services.switch_instance_status(session, instance, InstanceStatus.TERMINATING)
+ instances_services.switch_instance_status(session, instance, InstanceStatus.TERMINATED)
+
+ res = await session.execute(select(EventModel))
+ events = res.scalars().all()
+ assert len(events) == 2
+ assert {e.message for e in events} == {
+ "Instance status changed PENDING -> TERMINATING. Termination reason: ERROR (Some err)",
+ # Do not duplicate the termination reason in the second event
+ "Instance status changed TERMINATING -> TERMINATED",
+ }
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_includes_termination_reason_in_event_message_when_switching_directly_to_terminated(
+ self, test_db, session: AsyncSession
+ ) -> None:
+ project = await create_project(session=session)
+ instance = await create_instance(
+ session=session, project=project, status=InstanceStatus.PENDING
+ )
+ instance.termination_reason = InstanceTerminationReason.ERROR
+ instance.termination_reason_message = "Some err"
+ instances_services.switch_instance_status(session, instance, InstanceStatus.TERMINATED)
+
+ res = await session.execute(select(EventModel))
+ events = res.scalars().all()
+ assert len(events) == 1
+ assert events[0].message == (
+ "Instance status changed PENDING -> TERMINATED. Termination reason: ERROR (Some err)"
+ )
+
+
class TestFilterPoolInstances:
# TODO: Refactor filter_pool_instances to not depend on InstanceModel and simplify tests
@pytest.mark.asyncio
From c01b022a0a6611c272626540234f0f4fe7148fee Mon Sep 17 00:00:00 2001
From: Dmitry Meyer
Date: Tue, 20 Jan 2026 11:06:01 +0000
Subject: [PATCH 006/640] [runner] Restore `--home-dir` option as no-op (#3480)
Fixes: https://github.com/dstackai/dstack/issues/3474
---
runner/cmd/runner/main.go | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/runner/cmd/runner/main.go b/runner/cmd/runner/main.go
index c2ed94f0eb..c8125dc848 100644
--- a/runner/cmd/runner/main.go
+++ b/runner/cmd/runner/main.go
@@ -78,6 +78,12 @@ func mainInner() int {
Usage: "dstack server or user authorized key. May be specified multiple times",
Destination: &sshAuthorizedKeys,
},
+ // --home-dir is not used since 0.20.4, but the flag was retained as no-op
+ // for compatibility with pre-0.20.4 shims; remove the flag eventually
+ &cli.StringFlag{
+ Name: "home-dir",
+ Hidden: true,
+ },
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return start(ctx, tempDir, httpPort, sshPort, sshAuthorizedKeys, logLevel, Version)
From 65eacc7796f81aeb3dcff34e64b9e6ab4fa6fa7a Mon Sep 17 00:00:00 2001
From: Oleg
Date: Tue, 20 Jan 2026 23:45:48 +0300
Subject: [PATCH 007/640] [UI] Default fleet in project wizard (#3464)
* [UI] Default fleet in project wizard #373
* Minor cosmetic changes
* Fixes after review
* Was added create project wizard for oss
* Cosmetical changes + help info
* Was added create fleet wizard
* Fixes after review
* Refactoring after review
* Fixes after review
* Fixes after review
* Cosmetics
* Fixes after review
---------
Co-authored-by: peterschmidt85
---
frontend/src/api.ts | 1 +
.../ButtonWithConfirmation/index.tsx | 20 +-
.../components/ConfirmationDialog/index.tsx | 5 +-
.../components/ConfirmationDialog/slice.ts | 34 ++
.../components/form/Toogle/index.module.scss | 17 +
frontend/src/components/form/Toogle/index.tsx | 78 +++++
frontend/src/components/form/Toogle/types.ts | 13 +
frontend/src/components/index.ts | 1 +
frontend/src/hooks/index.ts | 1 +
frontend/src/hooks/useConfirmationDialog.ts | 27 ++
frontend/src/hooks/useNotifications.ts | 1 +
frontend/src/layouts/AppLayout/index.tsx | 7 +
frontend/src/locale/en.json | 40 ++-
.../Fleets/Add/FleetFormFields/constants.tsx | 115 +++++++
.../Fleets/Add/FleetFormFields/index.tsx | 79 +++++
.../pages/Fleets/Add/FleetFormFields/type.ts | 15 +
frontend/src/pages/Fleets/Add/index.tsx | 254 ++++++++++++++
frontend/src/pages/Fleets/Add/types.ts | 5 +
frontend/src/pages/Fleets/index.ts | 1 +
frontend/src/pages/Project/Add/index.tsx | 311 +++++++++++++++++-
.../src/pages/Project/CreateWizard/index.tsx | 297 +++++++++--------
.../src/pages/Project/CreateWizard/types.ts | 9 +-
frontend/src/pages/Project/Form/types.ts | 10 +-
frontend/src/pages/Project/List/index.tsx | 4 +-
.../components/NoFleetProjectAlert/index.tsx | 8 +-
frontend/src/pages/Project/constants.tsx | 32 ++
.../Project/hooks/useYupValidationResolver.ts | 38 +++
frontend/src/pages/User/Details/index.tsx | 8 +-
frontend/src/pages/User/List/index.tsx | 2 +
frontend/src/router.tsx | 6 +-
frontend/src/routes.ts | 4 +
frontend/src/services/fleet.ts | 20 +-
frontend/src/services/project.ts | 2 +-
frontend/src/store.ts | 2 +
frontend/src/types/fleet.d.ts | 16 +-
frontend/src/types/project.d.ts | 4 +
36 files changed, 1282 insertions(+), 205 deletions(-)
create mode 100644 frontend/src/components/ConfirmationDialog/slice.ts
create mode 100644 frontend/src/components/form/Toogle/index.module.scss
create mode 100644 frontend/src/components/form/Toogle/index.tsx
create mode 100644 frontend/src/components/form/Toogle/types.ts
create mode 100644 frontend/src/hooks/useConfirmationDialog.ts
create mode 100644 frontend/src/pages/Fleets/Add/FleetFormFields/constants.tsx
create mode 100644 frontend/src/pages/Fleets/Add/FleetFormFields/index.tsx
create mode 100644 frontend/src/pages/Fleets/Add/FleetFormFields/type.ts
create mode 100644 frontend/src/pages/Fleets/Add/index.tsx
create mode 100644 frontend/src/pages/Fleets/Add/types.ts
create mode 100644 frontend/src/pages/Project/constants.tsx
create mode 100644 frontend/src/pages/Project/hooks/useYupValidationResolver.ts
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index d58dbc7d38..144a21bc86 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -99,6 +99,7 @@ export const API = {
// Fleets
FLEETS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/list`,
FLEETS_DETAILS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/get`,
+ FLEETS_APPLY: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/apply`,
FLEETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/delete`,
FLEET_INSTANCES_DELETE: (projectName: IProject['project_name']) =>
`${API.BASE()}/project/${projectName}/fleets/delete_instances`,
diff --git a/frontend/src/components/ButtonWithConfirmation/index.tsx b/frontend/src/components/ButtonWithConfirmation/index.tsx
index 78c2793d9c..56ae78ad59 100644
--- a/frontend/src/components/ButtonWithConfirmation/index.tsx
+++ b/frontend/src/components/ButtonWithConfirmation/index.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
import Box from '@cloudscape-design/components/box';
import { Button } from '../Button';
@@ -13,20 +14,31 @@ export const ButtonWithConfirmation: React.FC = ({
confirmButtonLabel,
...props
}) => {
+ const { t } = useTranslation();
const [showDeleteConfirm, setShowConfirmDelete] = useState(false);
const toggleDeleteConfirm = () => {
setShowConfirmDelete((val) => !val);
};
- const content = typeof confirmContent === 'string' ? {confirmContent} : confirmContent;
-
const onConfirm = () => {
if (onClick) onClick();
setShowConfirmDelete(false);
};
+ const getContent = () => {
+ if (!confirmContent) {
+ return {t('confirm_dialog.message')} ;
+ }
+
+ if (typeof confirmContent === 'string') {
+ return {confirmContent} ;
+ }
+
+ return confirmContent;
+ };
+
return (
<>
@@ -36,8 +48,8 @@ export const ButtonWithConfirmation: React.FC = ({
onDiscard={toggleDeleteConfirm}
onConfirm={onConfirm}
title={confirmTitle}
- content={content}
- confirmButtonLabel={confirmButtonLabel}
+ content={getContent()}
+ confirmButtonLabel={confirmButtonLabel ?? t('common.delete')}
/>
>
);
diff --git a/frontend/src/components/ConfirmationDialog/index.tsx b/frontend/src/components/ConfirmationDialog/index.tsx
index ff69e36f02..082d15f8d8 100644
--- a/frontend/src/components/ConfirmationDialog/index.tsx
+++ b/frontend/src/components/ConfirmationDialog/index.tsx
@@ -9,7 +9,7 @@ import { IProps } from './types';
export const ConfirmationDialog: React.FC = ({
title: titleProp,
- content: contentProp,
+ content,
visible = false,
onDiscard,
onConfirm,
@@ -18,9 +18,8 @@ export const ConfirmationDialog: React.FC = ({
}) => {
const { t } = useTranslation();
const title = titleProp ?? t('confirm_dialog.title');
- const content = contentProp ?? {t('confirm_dialog.message')} ;
const cancelButtonLabel = cancelButtonLabelProp ?? t('common.cancel');
- const confirmButtonLabel = confirmButtonLabelProp ?? t('common.delete');
+ const confirmButtonLabel = confirmButtonLabelProp ?? t('common.ok');
return (
;
+};
+
+const initialState: ConfirmationDialogsStata = {
+ dialogs: [],
+};
+
+export const confirmationSlice = createSlice({
+ name: 'confirmation',
+ initialState,
+
+ reducers: {
+ open: (state, action: PayloadAction) => {
+ state.dialogs = [...state.dialogs, action.payload];
+ },
+ close: (state, action: PayloadAction) => {
+ state.dialogs = state.dialogs.filter((i) => i.uuid !== action.payload);
+ },
+ },
+});
+
+export const { open, close } = confirmationSlice.actions;
+
+export const selectConfirmationDialogs = (state: RootState) => state.confirmation.dialogs;
+
+export default confirmationSlice.reducer;
diff --git a/frontend/src/components/form/Toogle/index.module.scss b/frontend/src/components/form/Toogle/index.module.scss
new file mode 100644
index 0000000000..b57b20a850
--- /dev/null
+++ b/frontend/src/components/form/Toogle/index.module.scss
@@ -0,0 +1,17 @@
+@use '@cloudscape-design/design-tokens/index' as awsui;
+
+.labelWithInfo {
+ display: inline-flex;
+ align-items: center;
+}
+
+.divider {
+ display: inline-block;
+ height: 16px;
+ border-left: 1px solid awsui.$color-border-divider-default;
+ margin: 0 8px;
+}
+
+.info {
+ display: inline-flex;
+}
diff --git a/frontend/src/components/form/Toogle/index.tsx b/frontend/src/components/form/Toogle/index.tsx
new file mode 100644
index 0000000000..d8909e48e8
--- /dev/null
+++ b/frontend/src/components/form/Toogle/index.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { Controller, FieldValues } from 'react-hook-form';
+import FormField from '@cloudscape-design/components/form-field';
+import ToggleCSD from '@cloudscape-design/components/toggle';
+
+import { FormToggleProps } from './types';
+
+import styles from './index.module.scss';
+
+export const FormToggle = ({
+ name,
+ control,
+ rules,
+ label,
+ info,
+ constraintText,
+ description,
+ secondaryControl,
+ stretch,
+ leftContent,
+ toggleLabel,
+ onChange: onChangeProp,
+ toggleDescription,
+ toggleInfo,
+ ...props
+}: FormToggleProps) => {
+ return (
+ {
+ return (
+
+ {leftContent}
+
+ {
+ onChange(event.detail.checked);
+ onChangeProp?.(event);
+ }}
+ description={toggleDescription}
+ >
+ {(toggleLabel || toggleInfo) && (
+
+ {toggleLabel}
+ {toggleLabel && toggleInfo && }
+ {toggleInfo && (
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+ {toggleInfo}
+
+ )}
+
+ )}
+
+
+ );
+ }}
+ />
+ );
+};
diff --git a/frontend/src/components/form/Toogle/types.ts b/frontend/src/components/form/Toogle/types.ts
new file mode 100644
index 0000000000..7170922894
--- /dev/null
+++ b/frontend/src/components/form/Toogle/types.ts
@@ -0,0 +1,13 @@
+import { ReactNode } from 'react';
+import { ControllerProps, FieldValues } from 'react-hook-form';
+import { FormFieldProps } from '@cloudscape-design/components/form-field';
+import { ToggleProps } from '@cloudscape-design/components/toggle';
+
+export type FormToggleProps = Omit &
+ Omit &
+ Pick, 'control' | 'name' | 'rules'> & {
+ toggleDescription?: ReactNode;
+ leftContent?: ReactNode;
+ toggleLabel?: ReactNode | string;
+ toggleInfo?: ReactNode;
+ };
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index c8aa4013fb..3e5d353c96 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -73,6 +73,7 @@ export { ListEmptyMessage } from './ListEmptyMessage';
export { DetailsHeader } from './DetailsHeader';
export { Loader } from './Loader';
export { FormCheckbox } from './form/Checkbox';
+export { FormToggle } from './form/Toogle';
export { FormInput } from './form/Input';
export { FormMultiselect } from './form/Multiselect';
export { FormSelect } from './form/Select';
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index cc5aa6ac62..911e138757 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -2,6 +2,7 @@ export { default as useAppDispatch } from './useAppDispatch';
export { default as useAppSelector } from './useAppSelector';
export { useBreadcrumbs } from './useBreadcrumbs';
export { useNotifications } from './useNotifications';
+export { useConfirmationDialog } from './useConfirmationDialog';
export { useHelpPanel } from './useHelpPanel';
export { usePermissionGuard } from './usePermissionGuard';
export { useInfiniteScroll } from './useInfiniteScroll';
diff --git a/frontend/src/hooks/useConfirmationDialog.ts b/frontend/src/hooks/useConfirmationDialog.ts
new file mode 100644
index 0000000000..f6ba67cdc5
--- /dev/null
+++ b/frontend/src/hooks/useConfirmationDialog.ts
@@ -0,0 +1,27 @@
+import { close, open } from 'components/ConfirmationDialog/slice';
+import { IProps as ConfirmationDialogProps } from 'components/ConfirmationDialog/types';
+
+import { getUid } from '../libs';
+import useAppDispatch from './useAppDispatch';
+
+export const useConfirmationDialog = () => {
+ const dispatch = useAppDispatch();
+
+ const onDiscard = (uuid: string) => {
+ dispatch(close(uuid));
+ };
+
+ const openConfirmationDialog = (props: Omit) => {
+ const uuid = getUid();
+
+ dispatch(
+ open({
+ uuid,
+ ...props,
+ onDiscard: () => onDiscard(uuid),
+ }),
+ );
+ };
+
+ return [openConfirmationDialog];
+};
diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts
index d343a99a3d..61189303eb 100644
--- a/frontend/src/hooks/useNotifications.ts
+++ b/frontend/src/hooks/useNotifications.ts
@@ -12,6 +12,7 @@ const NOTIFICATION_LIFE_TIME = 6000;
type TUseNotificationsArgs = { temporary?: boolean; liveTime?: number } | undefined;
const defaultArgs: NonNullable> = { temporary: true, liveTime: NOTIFICATION_LIFE_TIME };
+
export const useNotifications = (args: TUseNotificationsArgs = defaultArgs) => {
const dispatch = useAppDispatch();
const notificationIdsSet = useRef(new Set>());
diff --git a/frontend/src/layouts/AppLayout/index.tsx b/frontend/src/layouts/AppLayout/index.tsx
index c0bfbed01c..d417779d41 100644
--- a/frontend/src/layouts/AppLayout/index.tsx
+++ b/frontend/src/layouts/AppLayout/index.tsx
@@ -10,6 +10,7 @@ import {
AppLayout as GenericAppLayout,
AppLayoutProps as GenericAppLayoutProps,
BreadcrumbGroup,
+ ConfirmationDialog,
HelpPanel,
Notifications,
SideNavigation,
@@ -35,6 +36,7 @@ import {
setToolsTab,
} from 'App/slice';
+import { selectConfirmationDialogs } from '../../components/ConfirmationDialog/slice';
import { AnnotationContext } from './AnnotationContext';
import { useSideNavigation } from './hooks';
import { TallyComponent } from './Tally';
@@ -71,6 +73,7 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const helpPanelContent = useAppSelector(selectHelpPanelContent);
const dispatch = useAppDispatch();
const { navLinks, activeHref } = useSideNavigation();
+ const confirmationDialogs = useAppSelector(selectConfirmationDialogs);
const onFollowHandler: SideNavigationProps['onFollow'] = (event) => {
event.preventDefault();
@@ -254,6 +257,10 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
/>
+
+ {confirmationDialogs.map(({ uuid, ...props }) => (
+
+ ))}
);
};
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index da3fe00fa9..5cd0808be4 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -1,11 +1,13 @@
{
"dstack": "Dstack",
"common": {
+ "ok": "OK",
"loading": "Loading",
"add": "Add",
"yes": "Yes",
"no": "No",
- "create": "Create {{text}}",
+ "create": "Create",
+ "create_wit_text": "Create {{text}}",
"edit": "Edit",
"delete": "Delete",
"remove": "Remove",
@@ -73,6 +75,7 @@
"runs": "Runs",
"models": "Models",
"fleets": "Fleets",
+ "fleet": "Fleet",
"project": "project",
"project_other": "Projects",
"general": "General",
@@ -197,7 +200,7 @@
},
"edit": {
"general": "General",
- "project_name": "Project name",
+ "project_name": "Name",
"owner": "Owner",
"project_name_description": "Only latin characters, dashes, underscores, and digits",
"project_type": "Project type",
@@ -205,16 +208,11 @@
"backends": "Backends",
"base_backends_description": "dstack will automatically collect offers from the following providers. Deselect providers you don’t want to use.",
"backends_description": "The following backends can be configured with your own cloud credentials in the project settings after the project is created.",
- "default_fleet": "Create default fleet",
- "default_fleet_description": "You can create default fleet for project",
- "fleet_name": "Fleet name",
- "fleet_name_description": "Only latin characters, dashes, underscores, and digits",
- "fleet_min_instances": "Min number of instances",
- "fleet_min_instances_description": "Only digits",
- "fleet_max_instances": "Max number of instances",
- "fleet_max_instances_description": "Only digits",
- "is_public": "Make project public",
- "is_public_description": "Public projects can be accessed by any user without being a member",
+ "create_default_fleet": "Create a default fleet",
+ "default_fleet": "Default fleet",
+ "default_fleet_description": "At least one fleet is required to run dev environments, tasks, or services.",
+ "is_public": "Public",
+ "is_public_description": "Allow any user join the project as a member",
"backend": "Backend",
"backend_config": "Backend config",
"backend_config_description": "Specify the backend config in the YAML format. Click Info for examples.",
@@ -226,7 +224,7 @@
"update_visibility_success": "Project visibility updated successfully",
"update_visibility_confirm_title": "Change project visibility",
"update_visibility_confirm_message": "Are you sure you want to change the project visibility? This will affect who can access this project.",
- "change_visibility": "Change visibility",
+ "change_visibility": "Change",
"project_visibility": "Visibility",
"project_visibility_description": "Control who can access this project",
"make_project_public": "Make project public",
@@ -479,8 +477,7 @@
"offer": "Offer",
"offer_description": "Select an offer for the dev environment.",
"name": "Name",
- "name_description": "The name of the run, e.g. 'my-dev-env'",
- "name_constraint": "If not specified, generated automatically",
+ "name_constraint": "Example: 'my-fleet' or 'default'. If not specified, generated automatically.",
"name_placeholder": "Optional",
"ide": "IDE",
"ide_description": "Select which IDE would you like to use with the dev environment.",
@@ -618,6 +615,19 @@
"spot": "Spot",
"started": "Started",
"price": "Price"
+ },
+ "edit": {
+ "name": "Name",
+ "name_description": "The name of the fleet, e.g. 'my-fleet'",
+ "name_placeholder": "Optional",
+ "name_constraint": "Example: 'my-fleet' or 'default'. If not specified, generated automatically.",
+ "min_instances": "Min number of instances",
+ "min_instances_description": "Set it `0` to provision instances only when required",
+ "max_instances": "Max number of instances",
+ "max_instances_description": "Required only if you want to set an upper limit",
+ "max_instances_placeholder": "Optional",
+ "idle_duration": "Idle duration",
+ "idle_duration_description": "Example: '0s', '1m', '1h'"
}
},
"volume": {
diff --git a/frontend/src/pages/Fleets/Add/FleetFormFields/constants.tsx b/frontend/src/pages/Fleets/Add/FleetFormFields/constants.tsx
new file mode 100644
index 0000000000..372c4dcfab
--- /dev/null
+++ b/frontend/src/pages/Fleets/Add/FleetFormFields/constants.tsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import { get } from 'lodash';
+import * as yup from 'yup';
+
+export const FLEET_MIN_INSTANCES_INFO = {
+ header: Min number of instances ,
+ body: (
+ <>
+
+ If you create a fleet here, it's recommended to set Min number of instances to 0. In
+ this case, dstack will provision instances only when you run a dev environment, task, or service.
+
+
+
+ If you set Min number of instances above 0, dstack will try to provision
+ them right away. Note, setting Min number of instances above 0 is supported for
+ VM-based backends only.
+
+
+
+ To learn more about fleets, see the{' '}
+
+ documentation
+
+ .
+
+ >
+ ),
+};
+
+export const FLEET_MAX_INSTANCES_INFO = {
+ header: Max number of instances ,
+ body: (
+ <>
+
+ Set Max number of instances only if you need to limit the number of instances in the fleet.
+
+
+
+ To learn more about fleets, see the{' '}
+
+ documentation
+
+ .
+
+ >
+ ),
+};
+
+export const FLEET_IDLE_DURATION_INFO = {
+ header: Idle duration ,
+ body: (
+ <>
+ Idle instances can be reused when you submit a dev environment, task, or service.
+
+
+ Set Idle duration to control how long instances stay idle before they are terminated.
+
+
+
+ Set Idle duration to 0s if you want instances to be terminated immediately after they
+ are no longer needed.
+
+
+
+ Note, dstack doesn't terminates if their total number would be below{' '}
+ Min number of instances.
+
+
+
+ To learn more about fleets, see the{' '}
+
+ documentation
+
+ .
+
+ >
+ ),
+};
+
+const requiredFieldError = 'This is required field';
+const numberFieldError = 'This is number field';
+
+export const getMinInstancesValidator = (maxInstancesFieldPath: string) =>
+ yup
+ .number()
+ .required(requiredFieldError)
+ .typeError(numberFieldError)
+ .min(0)
+ .test('is-smaller-than-max', 'The minimum value must be less than the maximum value.', (value, context) => {
+ const maxInstances = get(context.parent, maxInstancesFieldPath);
+
+ if (typeof maxInstances !== 'number' || typeof value !== 'number') {
+ return true;
+ }
+
+ return value <= maxInstances;
+ });
+
+export const getMaxInstancesValidator = (minInstancesFieldPath: string) =>
+ yup
+ .number()
+ .typeError(numberFieldError)
+ .min(1)
+ .test('is-greater-than-min', 'The maximum value must be greater than the minimum value', (value, context) => {
+ const minInstances = get(context.parent, minInstancesFieldPath);
+
+ if (typeof minInstances !== 'number' || typeof value !== 'number') {
+ return true;
+ }
+
+ return value >= minInstances;
+ });
+
+export const idleDurationValidator = yup.string().matches(/^\d+[smhdw]$/, 'Invalid duration');
diff --git a/frontend/src/pages/Fleets/Add/FleetFormFields/index.tsx b/frontend/src/pages/Fleets/Add/FleetFormFields/index.tsx
new file mode 100644
index 0000000000..96a12ef4c8
--- /dev/null
+++ b/frontend/src/pages/Fleets/Add/FleetFormFields/index.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { FormInput, InfoLink, SpaceBetween } from 'components';
+
+import { useHelpPanel } from 'hooks';
+
+import { FLEET_IDLE_DURATION_INFO, FLEET_MAX_INSTANCES_INFO, FLEET_MIN_INSTANCES_INFO } from './constants';
+import { FleetFormFieldsProps } from './type';
+
+import type { FieldValues } from 'react-hook-form/dist/types/fields';
+
+export function FleetFormFields({
+ control,
+ disabledAllFields,
+ fieldNamePrefix,
+}: FleetFormFieldsProps) {
+ const { t } = useTranslation();
+ const [openHelpPanel] = useHelpPanel();
+
+ const getFieldNameWitPrefix = (name: string) => {
+ if (!fieldNamePrefix) {
+ return name;
+ }
+
+ [fieldNamePrefix, name].join('.');
+ };
+
+ return (
+
+
+
+ openHelpPanel(FLEET_MIN_INSTANCES_INFO)} />}
+ label={t('fleets.edit.min_instances')}
+ constraintText={t('fleets.edit.min_instances_description')}
+ control={control}
+ //eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ name={getFieldNameWitPrefix(`min_instances`)}
+ disabled={disabledAllFields}
+ type="number"
+ />
+
+ openHelpPanel(FLEET_MAX_INSTANCES_INFO)} />}
+ label={t('fleets.edit.max_instances')}
+ constraintText={t('fleets.edit.max_instances_description')}
+ placeholder={t('fleets.edit.max_instances_placeholder')}
+ control={control}
+ //eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ name={getFieldNameWitPrefix(`max_instances`)}
+ disabled={disabledAllFields}
+ type="number"
+ />
+
+ openHelpPanel(FLEET_IDLE_DURATION_INFO)} />}
+ label={t('fleets.edit.idle_duration')}
+ constraintText={t('fleets.edit.idle_duration_description')}
+ control={control}
+ //eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ name={getFieldNameWitPrefix(`idle_duration`)}
+ disabled={disabledAllFields}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/Fleets/Add/FleetFormFields/type.ts b/frontend/src/pages/Fleets/Add/FleetFormFields/type.ts
new file mode 100644
index 0000000000..7351a60a6a
--- /dev/null
+++ b/frontend/src/pages/Fleets/Add/FleetFormFields/type.ts
@@ -0,0 +1,15 @@
+import type { FieldValues } from 'react-hook-form/dist/types/fields';
+import type { UseFormReturn } from 'react-hook-form/dist/types/form';
+
+export interface FleetFormFieldsProps
+ extends Pick, 'control'> {
+ fieldNamePrefix?: string;
+ disabledAllFields?: boolean;
+}
+
+export type FleetFormFields = {
+ name?: string;
+ min_instances: number;
+ max_instances?: number;
+ idle_duration?: string;
+};
diff --git a/frontend/src/pages/Fleets/Add/index.tsx b/frontend/src/pages/Fleets/Add/index.tsx
new file mode 100644
index 0000000000..b661d96cb0
--- /dev/null
+++ b/frontend/src/pages/Fleets/Add/index.tsx
@@ -0,0 +1,254 @@
+import React, { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { useNavigate, useParams } from 'react-router-dom';
+import { isNil } from 'lodash';
+import * as yup from 'yup';
+import { Box, WizardProps } from '@cloudscape-design/components';
+
+import { Container, InfoLink, KeyValuePairs, SpaceBetween, Wizard } from 'components';
+
+import { useBreadcrumbs, useConfirmationDialog, useHelpPanel, useNotifications } from 'hooks';
+import { ROUTES } from 'routes';
+import { useApplyFleetMutation } from 'services/fleet';
+
+import { DEFAULT_FLEET_INFO } from 'pages/Project/constants';
+import { useYupValidationResolver } from 'pages/Project/hooks/useYupValidationResolver';
+
+import { getMaxInstancesValidator, getMinInstancesValidator, idleDurationValidator } from './FleetFormFields/constants';
+import { FleetFormFields } from './FleetFormFields';
+
+import { IFleetWizardForm } from './types';
+
+const requiredFieldError = 'This is required field';
+const namesFieldError = 'Only latin characters, dashes, underscores, and digits';
+
+const fleetStepIndex = 0;
+
+const fleetValidationSchema = yup.object({
+ project_name: yup
+ .string()
+ .required(requiredFieldError)
+ .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError),
+ min_instances: getMinInstancesValidator('max_instances'),
+ max_instances: getMaxInstancesValidator('min_instances'),
+ idle_duration: idleDurationValidator,
+});
+
+export const FleetAdd: React.FC = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const params = useParams();
+ const paramProjectName = params.projectName ?? '';
+ const [openHelpPanel] = useHelpPanel();
+ const [pushNotification] = useNotifications();
+ const [openConfirmationDialog] = useConfirmationDialog();
+ const [applyFleet, { isLoading: isApplyingFleet }] = useApplyFleetMutation();
+ const [activeStepIndex, setActiveStepIndex] = useState(0);
+ const resolver = useYupValidationResolver(fleetValidationSchema);
+
+ const loading = isApplyingFleet;
+
+ const formMethods = useForm({
+ resolver,
+ defaultValues: {
+ project_name: paramProjectName,
+ min_instances: 0,
+ idle_duration: '5m',
+ },
+ });
+
+ const { handleSubmit, control, clearErrors, trigger, watch, getValues } = formMethods;
+ const formValues = watch();
+
+ const getFormValuesForFleetApplying = (): IApplyFleetPlanRequestRequest => {
+ const { min_instances, max_instances, idle_duration, name } = getValues();
+
+ return {
+ plan: {
+ spec: {
+ configuration: {
+ ...(name ? { name } : {}),
+ nodes: {
+ min: min_instances,
+ ...(max_instances ? { max: max_instances } : {}),
+ },
+ ...(idle_duration ? { idle_duration } : {}),
+ },
+ profile: {},
+ },
+ },
+ force: false,
+ };
+ };
+
+ useBreadcrumbs([
+ {
+ text: t('navigation.project_other'),
+ href: ROUTES.PROJECT.LIST,
+ },
+ {
+ text: paramProjectName,
+ href: ROUTES.PROJECT.DETAILS.FORMAT(paramProjectName),
+ },
+ {
+ text: t('navigation.fleets'),
+ href: ROUTES.FLEETS.LIST,
+ },
+ {
+ text: t('common.create_wit_text', { text: t('navigation.fleet') }),
+ href: ROUTES.FLEETS.ADD.FORMAT(paramProjectName),
+ },
+ ]);
+
+ const validateFleet = async () => {
+ return await trigger(['min_instances', 'max_instances', 'idle_duration']);
+ };
+
+ const emptyValidator = async () => Promise.resolve(true);
+
+ const onNavigate = ({
+ requestedStepIndex,
+ reason,
+ }: {
+ requestedStepIndex: number;
+ reason: WizardProps.NavigationReason;
+ }) => {
+ const stepValidators = [validateFleet, emptyValidator];
+
+ if (reason === 'next') {
+ stepValidators[activeStepIndex]?.().then((isValid) => {
+ if (isValid) {
+ if (activeStepIndex === fleetStepIndex && formValues?.['min_instances'] > 0) {
+ openConfirmationDialog({
+ title: 'Are sure want to set min instances above than 0?',
+ content: null,
+ onConfirm: () => setActiveStepIndex(requestedStepIndex),
+ });
+ } else {
+ setActiveStepIndex(requestedStepIndex);
+ }
+ }
+ });
+ } else {
+ setActiveStepIndex(requestedStepIndex);
+ }
+ };
+
+ const onNavigateHandler: WizardProps['onNavigate'] = ({ detail: { requestedStepIndex, reason } }) => {
+ onNavigate({ requestedStepIndex, reason });
+ };
+
+ const onCancelHandler = () => {
+ navigate(ROUTES.FLEETS.LIST);
+ };
+
+ const onSubmitWizard = async () => {
+ const isValid = await trigger();
+
+ const { project_name } = getValues();
+
+ if (!isValid) {
+ return;
+ }
+
+ clearErrors();
+
+ const request = applyFleet({
+ projectName: project_name,
+ ...getFormValuesForFleetApplying(),
+ }).unwrap();
+
+ request
+ .then((data) => {
+ pushNotification({
+ type: 'success',
+ content: t('fleets.create.success_notification'),
+ });
+
+ navigate(ROUTES.FLEETS.DETAILS.FORMAT(data.project_name, data.id));
+ })
+ .catch((error) => {
+ pushNotification({
+ type: 'error',
+ content: t('common.server_error', { error: error?.error ?? error }),
+ });
+ });
+ };
+
+ const onSubmit = () => {
+ if (activeStepIndex < 2) {
+ onNavigate({ requestedStepIndex: activeStepIndex + 1, reason: 'next' });
+ } else {
+ onSubmitWizard().catch(console.log);
+ }
+ };
+
+ const getDefaultFleetSummary = () => {
+ const summaryFields: Array = ['name', 'min_instances', 'max_instances', 'idle_duration'];
+
+ const result: string[] = [];
+
+ summaryFields.forEach((fieldName) => {
+ if (!isNil(formValues?.[fieldName])) {
+ result.push(`${t(`fleets.edit.${fieldName}`)}: ${formValues?.[fieldName]}`);
+ }
+ });
+
+ return result.join(', ');
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/pages/Fleets/Add/types.ts b/frontend/src/pages/Fleets/Add/types.ts
new file mode 100644
index 0000000000..9e36ef72a6
--- /dev/null
+++ b/frontend/src/pages/Fleets/Add/types.ts
@@ -0,0 +1,5 @@
+import { FleetFormFields } from './FleetFormFields/type';
+
+export interface IFleetWizardForm extends FleetFormFields {
+ project_name: IProject['project_name'];
+}
diff --git a/frontend/src/pages/Fleets/index.ts b/frontend/src/pages/Fleets/index.ts
index 18916ef2b5..e388010498 100644
--- a/frontend/src/pages/Fleets/index.ts
+++ b/frontend/src/pages/Fleets/index.ts
@@ -1,2 +1,3 @@
export { FleetList } from './List';
export { FleetDetails } from './Details';
+export { FleetAdd } from './Add';
diff --git a/frontend/src/pages/Project/Add/index.tsx b/frontend/src/pages/Project/Add/index.tsx
index 00c6ee47d1..14bbcc71fe 100644
--- a/frontend/src/pages/Project/Add/index.tsx
+++ b/frontend/src/pages/Project/Add/index.tsx
@@ -1,21 +1,118 @@
-import React from 'react';
+import React, { useState } from 'react';
+import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
+import { isNil } from 'lodash';
+import * as yup from 'yup';
+import { WizardProps } from '@cloudscape-design/components';
-import { ContentLayout, Header } from 'components';
+import { Container, FormInput, FormToggle, InfoLink, KeyValuePairs, SpaceBetween, Wizard } from 'components';
-import { useBreadcrumbs, useNotifications } from 'hooks';
-import { getServerError } from 'libs';
+import { useBreadcrumbs, useConfirmationDialog, useHelpPanel, useNotifications } from 'hooks';
+import { isResponseServerError, isResponseServerFormFieldError } from 'libs';
import { ROUTES } from 'routes';
+import { useApplyFleetMutation } from 'services/fleet';
import { useCreateProjectMutation } from 'services/project';
-import { ProjectForm } from '../Form';
+import { FleetFormFields } from 'pages/Fleets/Add/FleetFormFields';
+import {
+ getMaxInstancesValidator,
+ getMinInstancesValidator,
+ idleDurationValidator,
+} from 'pages/Fleets/Add/FleetFormFields/constants';
+
+import { DEFAULT_FLEET_INFO } from '../constants';
+import { useYupValidationResolver } from '../hooks/useYupValidationResolver';
+
+import { IProjectForm } from '../Form/types';
+import { FieldPath } from 'react-hook-form/dist/types/path';
+
+const requiredFieldError = 'This is required field';
+const namesFieldError = 'Only latin characters, dashes, underscores, and digits';
+
+const fleetStepIndex = 1;
+
+const projectValidationSchema = yup.object({
+ project_name: yup
+ .string()
+ .required(requiredFieldError)
+ .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError),
+ is_public: yup.boolean(),
+ fleet: yup.object().shape({
+ min_instances: yup.number().when('enable_default', {
+ is: true,
+ then: getMinInstancesValidator('max_instances'),
+ }),
+ max_instances: yup.number().when('enable_default', {
+ is: true,
+ then: getMaxInstancesValidator('min_instances'),
+ }),
+ idle_duration: yup.string().when('enable_default', {
+ is: true,
+ then: idleDurationValidator,
+ }),
+ }),
+});
export const ProjectAdd: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [pushNotification] = useNotifications();
+ const [openConfirmationDialog] = useConfirmationDialog();
+ const [openHelpPanel] = useHelpPanel();
const [createProject, { isLoading }] = useCreateProjectMutation();
+ const [applyFleet, { isLoading: isApplyingFleet }] = useApplyFleetMutation();
+ const [activeStepIndex, setActiveStepIndex] = useState(0);
+ const resolver = useYupValidationResolver(projectValidationSchema);
+
+ const loading = isLoading || isApplyingFleet;
+
+ const formMethods = useForm({
+ resolver,
+ defaultValues: {
+ is_public: false,
+ fleet: {
+ enable_default: true,
+ min_instances: 0,
+ idle_duration: '5m',
+ },
+ },
+ });
+
+ const { handleSubmit, control, setError, clearErrors, trigger, watch, getValues } = formMethods;
+ const formValues = watch();
+
+ const getFormValuesForServer = (): IProjectCreateRequestParams => {
+ const { project_name, is_public } = getValues();
+
+ return {
+ project_name,
+ is_public,
+ };
+ };
+
+ const getFormValuesForFleetApplying = (): IApplyFleetPlanRequestRequest => {
+ const {
+ fleet: { min_instances, max_instances, idle_duration, name },
+ } = getValues();
+
+ return {
+ plan: {
+ spec: {
+ configuration: {
+ ...(name ? { name } : {}),
+ nodes: {
+ min: min_instances,
+ ...(max_instances ? { max: max_instances } : {}),
+ },
+ ...(idle_duration ? { idle_duration } : {}),
+ },
+ profile: {},
+ },
+ },
+ force: false,
+ };
+ };
useBreadcrumbs([
{
@@ -23,40 +120,220 @@ export const ProjectAdd: React.FC = () => {
href: ROUTES.PROJECT.LIST,
},
{
- text: t('common.create', { text: t('navigation.project') }),
+ text: t('common.create_wit_text', { text: t('navigation.project') }),
href: ROUTES.PROJECT.ADD,
},
]);
+ const validateNameAndType = async () => {
+ return await trigger(['project_name', 'is_public']);
+ };
+ const validateFleet = async () => {
+ return await trigger(['fleet.enable_default', 'fleet.min_instances', 'fleet.max_instances', 'fleet.idle_duration']);
+ };
+
+ const emptyValidator = async () => Promise.resolve(true);
+
+ const onNavigate = ({
+ requestedStepIndex,
+ reason,
+ }: {
+ requestedStepIndex: number;
+ reason: WizardProps.NavigationReason;
+ }) => {
+ const stepValidators = [validateNameAndType, validateFleet, emptyValidator];
+
+ if (reason === 'next') {
+ stepValidators[activeStepIndex]?.().then((isValid) => {
+ if (isValid) {
+ if (activeStepIndex === fleetStepIndex && formValues?.['fleet']['min_instances'] > 0) {
+ openConfirmationDialog({
+ title: 'Are sure want to set min instances above than 0?',
+ content: null,
+ onConfirm: () => setActiveStepIndex(requestedStepIndex),
+ });
+ } else {
+ setActiveStepIndex(requestedStepIndex);
+ }
+ }
+ });
+ } else {
+ setActiveStepIndex(requestedStepIndex);
+ }
+ };
+
+ const onNavigateHandler: WizardProps['onNavigate'] = ({ detail: { requestedStepIndex, reason } }) => {
+ onNavigate({ requestedStepIndex, reason });
+ };
+
const onCancelHandler = () => {
navigate(ROUTES.PROJECT.LIST);
};
- const onSubmitHandler = async (data: IProject): Promise => {
- const request = createProject(data).unwrap();
+ const onSubmitWizard = async () => {
+ const isValid = await trigger();
+
+ const { fleet } = getValues();
+
+ if (!isValid) {
+ return;
+ }
+
+ clearErrors();
+
+ const request = createProject(getFormValuesForServer()).unwrap();
request
- .then((data) => {
+ .then(async (data) => {
pushNotification({
type: 'success',
content: t('projects.create.success_notification'),
});
+ if (fleet.enable_default) {
+ await applyFleet({
+ projectName: data.project_name,
+ ...getFormValuesForFleetApplying(),
+ }).unwrap();
+ }
+
navigate(ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(data.project_name));
})
.catch((error) => {
- pushNotification({
- type: 'error',
- content: t('common.server_error', { error: getServerError(error) }),
- });
+ const errorRequestData = error?.data;
+
+ if (isResponseServerError(errorRequestData)) {
+ errorRequestData.detail.forEach((error) => {
+ if (isResponseServerFormFieldError(error)) {
+ setError(error.loc.join('.') as FieldPath, { type: 'custom', message: error.msg });
+ } else {
+ pushNotification({
+ type: 'error',
+ content: t('common.server_error', { error: error.msg }),
+ });
+ }
+ });
+ } else {
+ pushNotification({
+ type: 'error',
+ content: t('common.server_error', { error: error?.error ?? error }),
+ });
+ }
});
+ };
+
+ const onSubmit = () => {
+ if (activeStepIndex < 2) {
+ onNavigate({ requestedStepIndex: activeStepIndex + 1, reason: 'next' });
+ } else {
+ onSubmitWizard().catch(console.log);
+ }
+ };
- return request;
+ const getDefaultFleetSummary = () => {
+ const summaryFields: Array = ['name', 'min_instances', 'max_instances', 'idle_duration'];
+
+ const result: string[] = [];
+
+ summaryFields.forEach((fieldName) => {
+ if (!isNil(formValues?.fleet?.[fieldName])) {
+ result.push(`${t(`fleets.edit.${fieldName}`)}: ${formValues['fleet'][fieldName]}`);
+ }
+ });
+
+ return result.join(', ');
};
return (
- {t('projects.create.page_title')}}>
-
-
+
);
};
diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx
index b4608bfdd8..4981efb6f1 100644
--- a/frontend/src/pages/Project/CreateWizard/index.tsx
+++ b/frontend/src/pages/Project/CreateWizard/index.tsx
@@ -1,43 +1,50 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
+import { isNil } from 'lodash';
import * as yup from 'yup';
import { WizardProps } from '@cloudscape-design/components';
import { TilesProps } from '@cloudscape-design/components/tiles';
import {
- // Box,
Cards,
Container,
FormCards,
- // FormCheckbox,
FormField,
FormInput,
- // FormMultiselect,
FormTiles,
+ FormToggle,
+ InfoLink,
KeyValuePairs,
SpaceBetween,
- // StatusIndicator,
Wizard,
} from 'components';
-import { useBreadcrumbs, useNotifications } from 'hooks';
+import { useBreadcrumbs, useConfirmationDialog, useHelpPanel, useNotifications } from 'hooks';
import { getServerError } from 'libs';
import { ROUTES } from 'routes';
import { useGetBackendBaseTypesQuery, useGetBackendTypesQuery } from 'services/backend';
+import { useApplyFleetMutation } from 'services/fleet';
import { useCreateWizardProjectMutation } from 'services/project';
+import { FleetFormFields } from '../../Fleets/Add/FleetFormFields';
+import {
+ getMaxInstancesValidator,
+ getMinInstancesValidator,
+ idleDurationValidator,
+} from '../../Fleets/Add/FleetFormFields/constants';
+import { DEFAULT_FLEET_INFO } from '../constants';
+import { useYupValidationResolver } from '../hooks/useYupValidationResolver';
import { projectTypeOptions } from './constants';
import { IProjectWizardForm } from './types';
-// import styles from './styles.module.scss';
-
const requiredFieldError = 'This is required field';
const minOneLengthError = 'Need to choose one or more';
const namesFieldError = 'Only latin characters, dashes, underscores, and digits';
-// const numberFieldError = 'This is number field';
+
+const fleetStepIndex = 2;
const projectValidationSchema = yup.object({
project_name: yup
@@ -49,88 +56,36 @@ const projectValidationSchema = yup.object({
is: 'gpu_marketplace',
then: yup.array().min(1, minOneLengthError).required(requiredFieldError),
}),
- // fleet_name: yup.string().when('enable_fleet', {
- // is: true,
- // then: yup
- // .string()
- // .required(requiredFieldError)
- // .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError),
- // }),
- // fleet_min_instances: yup.number().when('enable_fleet', {
- // is: true,
- // then: yup
- // .number()
- // .required(requiredFieldError)
- // .typeError(numberFieldError)
- // .min(1)
- // .test('is-smaller-than-man', 'The minimum value must be less than the maximum value.', (value, context) => {
- // const { fleet_max_instances } = context.parent;
- // if (typeof fleet_max_instances !== 'number' || typeof value !== 'number') return true;
- // return value <= fleet_max_instances;
- // }),
- // }),
- // fleet_max_instances: yup.number().when('enable_fleet', {
- // is: true,
- // then: yup
- // .number()
- // .required(requiredFieldError)
- // .typeError(numberFieldError)
- // .min(1)
- // .test('is-greater-than-min', 'The maximum value must be greater than the minimum value', (value, context) => {
- // const { fleet_min_instances } = context.parent;
- // if (typeof fleet_min_instances !== 'number' || typeof value !== 'number') return true;
- // return value >= fleet_min_instances;
- // }),
- // }),
+ fleet: yup.object().shape({
+ min_instances: yup.number().when('enable_default', {
+ is: true,
+ then: getMinInstancesValidator('max_instances'),
+ }),
+ max_instances: yup.number().when('enable_default', {
+ is: true,
+ then: getMaxInstancesValidator('min_instances'),
+ }),
+ idle_duration: yup.string().when('enable_default', {
+ is: true,
+ then: idleDurationValidator,
+ }),
+ }),
});
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-expect-error
-const useYupValidationResolver = (validationSchema) =>
- useCallback(
- async (data: IProjectWizardForm) => {
- try {
- const values = await validationSchema.validate(data, {
- abortEarly: false,
- });
-
- return {
- values,
- errors: {},
- };
- } catch (errors) {
- return {
- values: {},
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- errors: errors.inner.reduce(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- (allErrors, currentError) => ({
- ...allErrors,
- [currentError.path]: {
- type: currentError.type ?? 'validation',
- message: currentError.message,
- },
- }),
- {},
- ),
- };
- }
- },
- [validationSchema],
- );
-
export const CreateProjectWizard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [pushNotification] = useNotifications();
const [activeStepIndex, setActiveStepIndex] = useState(0);
+ const [openHelpPanel] = useHelpPanel();
const [createProject, { isLoading }] = useCreateWizardProjectMutation();
+ const [applyFleet, { isLoading: isApplyingFleet }] = useApplyFleetMutation();
const { data: backendBaseTypesData, isLoading: isBackendBaseTypesLoading } = useGetBackendBaseTypesQuery();
const { data: backendTypesData, isLoading: isBackendTypesLoading } = useGetBackendTypesQuery();
- const loading = isLoading;
+ const [openConfirmationDialog] = useConfirmationDialog();
+
+ const loading = isLoading || isApplyingFleet;
useBreadcrumbs([
{
@@ -138,7 +93,7 @@ export const CreateProjectWizard: React.FC = () => {
href: ROUTES.PROJECT.LIST,
},
{
- text: t('common.create', { text: t('navigation.project') }),
+ text: t('common.create_wit_text', { text: t('navigation.project') }),
href: ROUTES.PROJECT.ADD,
},
]);
@@ -166,10 +121,19 @@ export const CreateProjectWizard: React.FC = () => {
}, [backendTypesData]);
const resolver = useYupValidationResolver(projectValidationSchema);
+
const formMethods = useForm({
resolver,
- defaultValues: { project_type: 'gpu_marketplace', enable_fleet: true, fleet_min_instances: 0 },
+ defaultValues: {
+ project_type: 'gpu_marketplace',
+ fleet: {
+ enable_default: true,
+ min_instances: 0,
+ idle_duration: '5m',
+ },
+ },
});
+
const { handleSubmit, control, watch, trigger, formState, getValues, setValue, setError } = formMethods;
const formValues = watch();
@@ -188,6 +152,29 @@ export const CreateProjectWizard: React.FC = () => {
};
};
+ const getFormValuesForFleetApplying = (): IApplyFleetPlanRequestRequest => {
+ const {
+ fleet: { min_instances, max_instances, idle_duration, name },
+ } = getValues();
+
+ return {
+ plan: {
+ spec: {
+ configuration: {
+ ...(name ? { name } : {}),
+ nodes: {
+ min: min_instances,
+ ...(max_instances ? { max: max_instances } : {}),
+ },
+ ...(idle_duration ? { idle_duration } : {}),
+ },
+ profile: {},
+ },
+ },
+ force: false,
+ };
+ };
+
const validateNameAndType = async () => {
try {
const yupValidationResult = await trigger(['project_type', 'project_name']);
@@ -224,6 +211,10 @@ export const CreateProjectWizard: React.FC = () => {
return Promise.resolve(true);
};
+ const validateFleet = async () => {
+ return await trigger(['fleet.enable_default', 'fleet.min_instances', 'fleet.max_instances', 'fleet.idle_duration']);
+ };
+
const emptyValidator = async () => Promise.resolve(true);
const onNavigate = ({
@@ -233,12 +224,20 @@ export const CreateProjectWizard: React.FC = () => {
requestedStepIndex: number;
reason: WizardProps.NavigationReason;
}) => {
- const stepValidators = [validateNameAndType, validateBackends, emptyValidator];
+ const stepValidators = [validateNameAndType, validateBackends, validateFleet, emptyValidator];
if (reason === 'next') {
stepValidators[activeStepIndex]?.().then((isValid) => {
if (isValid) {
- setActiveStepIndex(requestedStepIndex);
+ if (activeStepIndex === fleetStepIndex && formValues?.['fleet']['min_instances'] > 0) {
+ openConfirmationDialog({
+ title: 'Are sure want to set min instances above than 0?',
+ content: null,
+ onConfirm: () => setActiveStepIndex(requestedStepIndex),
+ });
+ } else {
+ setActiveStepIndex(requestedStepIndex);
+ }
}
});
} else {
@@ -274,6 +273,8 @@ export const CreateProjectWizard: React.FC = () => {
const onSubmitWizard = async () => {
const isValid = await trigger();
+ const { fleet } = getValues();
+
if (!isValid) {
return;
}
@@ -281,7 +282,14 @@ export const CreateProjectWizard: React.FC = () => {
const request = createProject(getFormValuesForServer()).unwrap();
request
- .then((data) => {
+ .then(async (data) => {
+ if (fleet.enable_default) {
+ await applyFleet({
+ projectName: data.project_name,
+ ...getFormValuesForFleetApplying(),
+ }).unwrap();
+ }
+
pushNotification({
type: 'success',
content: t('projects.create.success_notification'),
@@ -298,13 +306,32 @@ export const CreateProjectWizard: React.FC = () => {
};
const onSubmit = () => {
- if (activeStepIndex < 2) {
+ if (activeStepIndex < 3) {
onNavigate({ requestedStepIndex: activeStepIndex + 1, reason: 'next' });
} else {
onSubmitWizard().catch(console.log);
}
};
+ const getDefaultFleetSummary = () => {
+ const summaryFields: Array = [
+ 'name',
+ 'min_instances',
+ 'max_instances',
+ 'idle_duration',
+ ];
+
+ const result: string[] = [];
+
+ summaryFields.forEach((fieldName) => {
+ if (!isNil(formValues?.fleet?.[fieldName])) {
+ result.push(`${t(`fleets.edit.${fieldName}`)}: ${formValues['fleet'][fieldName]}`);
+ }
+ });
+
+ return result.join(', ');
+ };
+
return (
@@ -217,17 +208,17 @@ Easy to use with on-prem clusters
-
- Kubernetes
+
+ SSH fleets
-
-
- SSH fleets
+
+
+ Kubernetes
Single-node & distributed tasks
Tasks
-
-
- Clusters
-
-
@@ -320,7 +305,7 @@ Single-node & distributed tasks
-
Scalable service endpoints
+
Scalable model inference
With dstack , you can easily deploy any model as a secure,
@@ -334,6 +319,12 @@
Scalable service endpoints
Services
+
+
+ Gateways
+
+
@@ -596,10 +587,12 @@ Get started in minutes
-
+
+ Installation
+
+
diff --git a/examples/clusters/nccl-rccl-tests/README.md b/examples/clusters/nccl-rccl-tests/README.md
index 7248a4f422..0a2a138b1a 100644
--- a/examples/clusters/nccl-rccl-tests/README.md
+++ b/examples/clusters/nccl-rccl-tests/README.md
@@ -3,7 +3,7 @@
This example shows how to run [NCCL](https://github.com/NVIDIA/nccl-tests) or [RCCL](https://github.com/ROCm/rccl-tests) tests on a cluster using [distributed tasks](https://dstack.ai/docs/concepts/tasks#distributed-tasks).
!!! info "Prerequisites"
- Before running a distributed task, make sure to create a fleet with `placement` set to `cluster` (can be a [managed fleet](https://dstack.ai/docs/concepts/fleets#backend-placement) or an [SSH fleet](https://dstack.ai/docs/concepts/fleets#ssh-placement)).
+ Before running a distributed task, make sure to create a fleet with `placement` set to `cluster` (can be a [managed fleet](https://dstack.ai/docs/concepts/fleets#cluster-placement) or an [SSH fleet](https://dstack.ai/docs/concepts/fleets#ssh-placement)).
## Running as a task
diff --git a/examples/distributed-training/axolotl/README.md b/examples/distributed-training/axolotl/README.md
index 9ddd77a363..1454732ad5 100644
--- a/examples/distributed-training/axolotl/README.md
+++ b/examples/distributed-training/axolotl/README.md
@@ -3,7 +3,7 @@
This example walks you through how to run distributed fine-tune using [Axolotl](https://github.com/axolotl-ai-cloud/axolotl) and [distributed tasks](https://dstack.ai/docs/concepts/tasks#distributed-tasks).
!!! info "Prerequisites"
- Before running a distributed task, make sure to create a fleet with `placement` set to `cluster` (can be a [managed fleet](https://dstack.ai/docs/concepts/fleets#backend-placement) or an [SSH fleet](https://dstack.ai/docs/concepts/fleets#ssh-placement)).
+ Before running a distributed task, make sure to create a fleet with `placement` set to `cluster` (can be a [managed fleet](https://dstack.ai/docs/concepts/fleets#cluster-placement) or an [SSH fleet](https://dstack.ai/docs/concepts/fleets#ssh-placement)).
## Define a configuration
diff --git a/examples/distributed-training/ray-ragen/README.md b/examples/distributed-training/ray-ragen/README.md
index e79f27f788..32ce0173fd 100644
--- a/examples/distributed-training/ray-ragen/README.md
+++ b/examples/distributed-training/ray-ragen/README.md
@@ -6,7 +6,7 @@ to fine-tune an agent on multiple nodes.
Under the hood `RAGEN` uses [verl](https://github.com/volcengine/verl) for Reinforcement Learning and [Ray](https://docs.ray.io/en/latest/) for distributed training.
!!! info "Prerequisites"
- Before running a distributed task, make sure to create a fleet with `placement` set to `cluster` (can be a [managed fleet](https://dstack.ai/docs/concepts/fleets#backend-placement) or an [SSH fleet](https://dstack.ai/docs/concepts/fleets#ssh-placement)).
+ Before running a distributed task, make sure to create a fleet with `placement` set to `cluster` (can be a [managed fleet](https://dstack.ai/docs/concepts/fleets#cluster-placement) or an [SSH fleet](https://dstack.ai/docs/concepts/fleets#ssh-placement)).
## Run a Ray cluster
diff --git a/examples/distributed-training/trl/README.md b/examples/distributed-training/trl/README.md
index c6231e5170..9df482da52 100644
--- a/examples/distributed-training/trl/README.md
+++ b/examples/distributed-training/trl/README.md
@@ -3,7 +3,7 @@
This example walks you through how to run distributed fine-tune using [TRL](https://github.com/huggingface/trl), [Accelerate](https://github.com/huggingface/accelerate) and [Deepspeed](https://github.com/deepspeedai/DeepSpeed).
!!! info "Prerequisites"
- Before running a distributed task, make sure to create a fleet with `placement` set to `cluster` (can be a [managed fleet](https://dstack.ai/docs/concepts/fleets#backend-placement) or an [SSH fleet](https://dstack.ai/docs/concepts/fleets#ssh-placement)).
+ Before running a distributed task, make sure to create a fleet with `placement` set to `cluster` (can be a [managed fleet](https://dstack.ai/docs/concepts/fleets#cluster-placement) or an [SSH fleet](https://dstack.ai/docs/concepts/fleets#ssh-placement)).
## Define a configuration
diff --git a/mkdocs.yml b/mkdocs.yml
index 07eed5f3b7..5dcdd69999 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -74,15 +74,13 @@ plugins:
- docs/concepts/tasks.md: How to run tasks - for training or fine-tuning, including distributed tasks
- docs/concepts/services.md: How to deploy services - for model inference or web apps
- docs/concepts/volumes.md: How to manage volumes - for persistent storage or caching
+ - docs/concepts/gateways.md: How to manage gateways - enabling auto-scaling, rate limits, and custom domains
- docs/concepts/secrets.md: How to manage secrets - for API keys or other sensitive data
- docs/concepts/projects.md: How to manage projects - for managing separate teams
- - docs/concepts/gateways.md: How to manage gateways - enabling auto-scaling, rate limits, and custom domains
+ - docs/concepts/metrics.md: How to manage gateways - enabling auto-scaling, rate limits, and custom domains
Guides:
- - docs/guides/clusters.md: How to work with clusters - for distributed tasks
- - docs/guides/kubernetes.md: How to work with Kubernetes
- docs/guides/server-deployment.md: Detailed guide on how to deploy the dstack server
- docs/guides/troubleshooting.md: Common issues and how to troubleshoot them
- - docs/guides/metrics.md: How to monitor metrics
- docs/guides/protips.md: Pro tips - tips and tricks to use dstack more efficiently
Examples:
- examples/single-node-training/trl/index.md: TRL
@@ -149,8 +147,8 @@ plugins:
"blog/data-centers-and-private-clouds.md": "blog/posts/gpu-blocks-and-proxy-jump.md"
"blog/distributed-training-with-aws-efa.md": "examples/clusters/aws/index.md"
"blog/dstack-stats.md": "blog/posts/dstack-metrics.md"
- "docs/concepts/metrics.md": "docs/guides/metrics.md"
- "docs/guides/monitoring.md": "docs/guides/metrics.md"
+ "docs/guides/metrics.md": "docs/concepts/metrics.md"
+ "docs/guides/monitoring.md": "docs/concepts/metrics.md"
"blog/nvidia-and-amd-on-vultr.md.md": "blog/posts/nvidia-and-amd-on-vultr.md"
"examples/misc/nccl-tests/index.md": "examples/clusters/nccl-rccl-tests/index.md"
"examples/misc/a3high-clusters/index.md": "examples/clusters/gcp/index.md"
@@ -268,19 +266,18 @@ nav:
- Tasks: docs/concepts/tasks.md
- Services: docs/concepts/services.md
- Volumes: docs/concepts/volumes.md
- - Secrets: docs/concepts/secrets.md
- - Projects: docs/concepts/projects.md
- - Gateways: docs/concepts/gateways.md
+ - More:
+ - Gateways: docs/concepts/gateways.md
+ - Secrets: docs/concepts/secrets.md
+ - Projects: docs/concepts/projects.md
+ - Metrics: docs/concepts/metrics.md
- Guides:
- - Clusters: docs/guides/clusters.md
- - Kubernetes: docs/guides/kubernetes.md
- Server deployment: docs/guides/server-deployment.md
- Troubleshooting: docs/guides/troubleshooting.md
- - Metrics: docs/guides/metrics.md
- Protips: docs/guides/protips.md
- Upgrade: docs/guides/upgrade.md
- - Migration:
- - Slurm: docs/guides/migration/slurm.md
+ - Migration:
+ - Slurm: docs/guides/migration/slurm.md
- Reference:
- .dstack.yml:
- dev-environment: docs/reference/dstack.yml/dev-environment.md
From b93476e187450ecbf0ac729a5c195d278580e1b3 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Tue, 27 Jan 2026 14:36:58 +0000
Subject: [PATCH 030/640] Support secret events in API, CLI, and UI (#3504)
---
.../Events/List/hooks/useColumnDefinitions.tsx | 13 +++++++++++++
.../src/pages/Events/List/hooks/useFilters.ts | 9 +++++++++
frontend/src/types/event.d.ts | 5 +++--
src/dstack/_internal/cli/commands/event.py | 11 +++++++++++
src/dstack/_internal/cli/services/events.py | 1 +
src/dstack/_internal/core/models/events.py | 1 +
src/dstack/_internal/server/routers/events.py | 1 +
src/dstack/_internal/server/schemas/events.py | 11 +++++++++++
src/dstack/_internal/server/services/events.py | 17 +++++++++++++++++
src/dstack/api/server/_events.py | 2 ++
10 files changed, 69 insertions(+), 2 deletions(-)
diff --git a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx
index d6e5b846ea..bc9a07aa0c 100644
--- a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx
+++ b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx
@@ -138,6 +138,19 @@ export const useColumnsDefinitions = () => {
);
+ case 'secret':
+ return (
+
+ Secret{' '}
+ {target.project_name && (
+
+ {target.project_name}
+
+ )}
+ /{target.name}
+
+ );
+
default:
return '---';
}
diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts
index d463770b30..e6a97c71e8 100644
--- a/frontend/src/pages/Events/List/hooks/useFilters.ts
+++ b/frontend/src/pages/Events/List/hooks/useFilters.ts
@@ -19,6 +19,7 @@ type RequestParamsKeys = keyof Pick<
| 'target_jobs'
| 'target_volumes'
| 'target_gateways'
+ | 'target_secrets'
| 'within_projects'
| 'within_fleets'
| 'within_runs'
@@ -35,6 +36,7 @@ const filterKeys: Record = {
TARGET_JOBS: 'target_jobs',
TARGET_VOLUMES: 'target_volumes',
TARGET_GATEWAYS: 'target_gateways',
+ TARGET_SECRETS: 'target_secrets',
WITHIN_PROJECTS: 'within_projects',
WITHIN_FLEETS: 'within_fleets',
WITHIN_RUNS: 'within_runs',
@@ -53,6 +55,7 @@ const multipleChoiseKeys: RequestParamsKeys[] = [
'target_jobs',
'target_volumes',
'target_gateways',
+ 'target_secrets',
'within_projects',
'within_fleets',
'within_runs',
@@ -69,6 +72,7 @@ const targetTypes = [
{ label: 'Job', value: 'job' },
{ label: 'Volume', value: 'volume' },
{ label: 'Gateway', value: 'gateway' },
+ { label: 'Secret', value: 'secret' },
];
export const useFilters = () => {
@@ -171,6 +175,11 @@ export const useFilters = () => {
operators: ['='],
propertyLabel: 'Target gateways',
},
+ {
+ key: filterKeys.TARGET_SECRETS,
+ operators: ['='],
+ propertyLabel: 'Target secrets',
+ },
{
key: filterKeys.WITHIN_PROJECTS,
diff --git a/frontend/src/types/event.d.ts b/frontend/src/types/event.d.ts
index 618ea6673f..dd0147fe15 100644
--- a/frontend/src/types/event.d.ts
+++ b/frontend/src/types/event.d.ts
@@ -1,4 +1,4 @@
-declare type TEventTargetType = 'project' | 'user' | 'fleet' | 'instance' | 'run' | 'job' | 'volume' | 'gateway';
+declare type TEventTargetType = 'project' | 'user' | 'fleet' | 'instance' | 'run' | 'job' | 'volume' | 'gateway' | 'secret';
declare type TEventListRequestParams = Omit & {
prev_recorded_at?: string;
@@ -10,6 +10,7 @@ declare type TEventListRequestParams = Omit EventListFilters:
" Update the server to 0.20.7 or higher or remove --target-gateway."
)
filters.target_gateways.append(id)
+ elif args.target_secrets:
+ filters.target_secrets = [
+ api.client.secrets.get(api.project, name=name).id for name in args.target_secrets
+ ]
if args.within_fleets:
filters.within_fleets = [
diff --git a/src/dstack/_internal/cli/services/events.py b/src/dstack/_internal/cli/services/events.py
index 11f764bd15..0f0eb0f4b4 100644
--- a/src/dstack/_internal/cli/services/events.py
+++ b/src/dstack/_internal/cli/services/events.py
@@ -18,6 +18,7 @@ class EventListFilters:
target_runs: Optional[list[uuid.UUID]] = None
target_volumes: Optional[list[uuid.UUID]] = None
target_gateways: Optional[list[uuid.UUID]] = None
+ target_secrets: Optional[list[uuid.UUID]] = None
within_projects: Optional[list[uuid.UUID]] = None
within_fleets: Optional[list[uuid.UUID]] = None
within_runs: Optional[list[uuid.UUID]] = None
diff --git a/src/dstack/_internal/core/models/events.py b/src/dstack/_internal/core/models/events.py
index 289c4fc674..f2efb80d0e 100644
--- a/src/dstack/_internal/core/models/events.py
+++ b/src/dstack/_internal/core/models/events.py
@@ -18,6 +18,7 @@ class EventTargetType(str, Enum):
JOB = "job"
VOLUME = "volume"
GATEWAY = "gateway"
+ SECRET = "secret"
class EventTarget(CoreModel):
diff --git a/src/dstack/_internal/server/routers/events.py b/src/dstack/_internal/server/routers/events.py
index 4250eb4d7a..036a8b2be8 100644
--- a/src/dstack/_internal/server/routers/events.py
+++ b/src/dstack/_internal/server/routers/events.py
@@ -46,6 +46,7 @@ async def list_events(
target_jobs=body.target_jobs,
target_volumes=body.target_volumes,
target_gateways=body.target_gateways,
+ target_secrets=body.target_secrets,
within_projects=body.within_projects,
within_fleets=body.within_fleets,
within_runs=body.within_runs,
diff --git a/src/dstack/_internal/server/schemas/events.py b/src/dstack/_internal/server/schemas/events.py
index 30f7fe3244..3899b1f398 100644
--- a/src/dstack/_internal/server/schemas/events.py
+++ b/src/dstack/_internal/server/schemas/events.py
@@ -102,6 +102,17 @@ class ListEventsRequest(CoreModel):
max_items=MAX_FILTER_ITEMS,
),
] = None
+ target_secrets: Annotated[
+ Optional[list[uuid.UUID]],
+ Field(
+ description=(
+ "List of secret IDs."
+ " The response will only include events that target the specified secrets"
+ ),
+ min_items=MIN_FILTER_ITEMS,
+ max_items=MAX_FILTER_ITEMS,
+ ),
+ ] = None
within_projects: Annotated[
Optional[list[uuid.UUID]],
Field(
diff --git a/src/dstack/_internal/server/services/events.py b/src/dstack/_internal/server/services/events.py
index c6d35a4577..d46b43e201 100644
--- a/src/dstack/_internal/server/services/events.py
+++ b/src/dstack/_internal/server/services/events.py
@@ -20,6 +20,7 @@
MemberModel,
ProjectModel,
RunModel,
+ SecretModel,
UserModel,
VolumeModel,
)
@@ -93,6 +94,7 @@ def from_model(
JobModel,
ProjectModel,
RunModel,
+ SecretModel,
UserModel,
VolumeModel,
],
@@ -139,6 +141,13 @@ def from_model(
id=model.id,
name=model.run_name,
)
+ if isinstance(model, SecretModel):
+ return Target(
+ type=EventTargetType.SECRET,
+ project_id=model.project_id or model.project.id,
+ id=model.id,
+ name=model.name,
+ )
if isinstance(model, UserModel):
return Target(
type=EventTargetType.USER,
@@ -232,6 +241,7 @@ async def list_events(
target_jobs: Optional[list[uuid.UUID]],
target_volumes: Optional[list[uuid.UUID]],
target_gateways: Optional[list[uuid.UUID]],
+ target_secrets: Optional[list[uuid.UUID]],
within_projects: Optional[list[uuid.UUID]],
within_fleets: Optional[list[uuid.UUID]],
within_runs: Optional[list[uuid.UUID]],
@@ -315,6 +325,13 @@ async def list_events(
EventTargetModel.entity_id.in_(target_gateways),
)
)
+ if target_secrets is not None:
+ target_filters.append(
+ and_(
+ EventTargetModel.entity_type == EventTargetType.SECRET,
+ EventTargetModel.entity_id.in_(target_secrets),
+ )
+ )
if within_projects is not None:
target_filters.append(EventTargetModel.entity_project_id.in_(within_projects))
if within_fleets is not None:
diff --git a/src/dstack/api/server/_events.py b/src/dstack/api/server/_events.py
index d403fb2427..5aff9c9d21 100644
--- a/src/dstack/api/server/_events.py
+++ b/src/dstack/api/server/_events.py
@@ -31,6 +31,7 @@ def list(
# NOTE: New parameters go here. Avoid positional parameters, they can break compatibility.
target_volumes: Optional[list[UUID]] = None,
target_gateways: Optional[list[UUID]] = None,
+ target_secrets: Optional[list[UUID]] = None,
) -> list[Event]:
if prev_recorded_at is not None:
# Time zones other than UTC are misinterpreted by the server:
@@ -45,6 +46,7 @@ def list(
target_jobs=target_jobs,
target_volumes=target_volumes,
target_gateways=target_gateways,
+ target_secrets=target_secrets,
within_projects=within_projects,
within_fleets=within_fleets,
within_runs=within_runs,
From ca56b3b9f9d43879d866242548e4f5e9706e6581 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Tue, 27 Jan 2026 21:21:00 +0100
Subject: [PATCH 031/640] [Docs] Events #3397 (#3506)
* [Docs] Events #3397
* Update docs/docs/concepts/events.md
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* Update docs/docs/concepts/events.md
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* Update docs/docs/concepts/events.md
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* Update docs/docs/concepts/events.md
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* Update docs/docs/concepts/events.md
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* Update docs/docs/concepts/events.md
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
---------
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
---
docs/docs/concepts/events.md | 70 +++++++++++++++++++++++++
docs/docs/reference/cli/dstack/event.md | 5 +-
mkdocs.yml | 4 +-
3 files changed, 75 insertions(+), 4 deletions(-)
create mode 100644 docs/docs/concepts/events.md
diff --git a/docs/docs/concepts/events.md b/docs/docs/concepts/events.md
new file mode 100644
index 0000000000..fb6e0938a2
--- /dev/null
+++ b/docs/docs/concepts/events.md
@@ -0,0 +1,70 @@
+# Events
+
+Events provide a chronological record of notable state changes and operations affecting `dstack` resources. They are designed for auditing, debugging, and understanding the lifecycle of runs, jobs, fleets, and other resources.
+
+Each event includes the following fields:
+
+| Field | Description |
+| --------- | ----------------------------------------------------------- |
+| Timestamp | When the event occurred |
+| Actor | The user or system that initiated the change, if applicable |
+| Targets | The resources affected by the event |
+| Message | A description of the change or additional event details |
+
+Events can be queried by targeting a specific resource or within a group of related resources. For example, you can query events targeting a particular job, or query events within a run, including the run itself and all of its jobs.
+
+Events are accessible through the UI, CLI, and API.
+
+## UI
+
+The UI allows you to query events either globally on the dedicated `Events` page or within a specific group on the page of a run, job, fleet, and other resources.
+
+### Global page
+
+The global page shows events from all projects that the user has access to and allows filtering by many fields.
+
+{ width=800 }
+
+This page allows you to query events targeting a specific resource or within a particular group.
+
+### Resource page
+
+The resource page shows events within that specific group. For example, if you open a run and switch to the `Events` tab, you will see all events about that run and its jobs.
+
+{ width=800 }
+
+## CLI
+
+To query events via the CLI, use the `dstack event` command. This command provides several arguments that allow filtering by target and within scopes.
+
+Here is an example of querying all events within a particular run:
+
+
+
+```shell
+$ dstack event --within-run cursor
+
+[2026-01-21 13:09:37] [👤admin] [run cursor] Run submitted. Status: SUBMITTED
+[2026-01-21 13:09:37] [job cursor-0-0] Job created on run submission. Status: SUBMITTED
+[2026-01-21 13:09:57] [job cursor-0-0] Job status changed SUBMITTED -> PROVISIONING
+[2026-01-21 13:09:58] [job cursor-0-0, instance some-fleet-0] Instance created for job. Instance status: PROVISIONING
+[2026-01-21 13:09:59] [run cursor] Run status changed SUBMITTED -> PROVISIONING
+[2026-01-21 13:11:22] [job cursor-0-0] Job status changed PROVISIONING -> PULLING
+[2026-01-21 13:11:49] [job cursor-0-0] Job status changed PULLING -> RUNNING
+[2026-01-21 13:11:51] [run cursor] Run status changed PROVISIONING -> RUNNING
+[2026-01-21 13:18:41] [👤admin] [run cursor] Run status changed RUNNING -> TERMINATING. Termination reason: STOPPED_BY_USER
+[2026-01-21 13:18:48] [job cursor-0-0] Job status changed RUNNING -> TERMINATING. Termination reason: TERMINATED_BY_USER
+[2026-01-21 13:19:05] [instance some-fleet-0, job cursor-0-0] Job unassigned from instance. Instance blocks: 0/1 busy
+[2026-01-21 13:19:05] [job cursor-0-0] Job status changed TERMINATING -> TERMINATED
+[2026-01-21 13:19:07] [run cursor] Run status changed TERMINATING -> TERMINATED
+```
+
+
+
+To see all supported arguments, check the [reference](../reference/cli/dstack/event.md).
+
+If you invoke the command without arguments, it will include all events targeting resources in the project.
+
+## TTL
+
+By default, `dstack` stores each event for 30 days and then deletes it. This can be overridden by server administrators using the `DSTACK_SERVER_EVENTS_TTL_SECONDS` environment variable.
diff --git a/docs/docs/reference/cli/dstack/event.md b/docs/docs/reference/cli/dstack/event.md
index 9854609208..8f90e456c9 100644
--- a/docs/docs/reference/cli/dstack/event.md
+++ b/docs/docs/reference/cli/dstack/event.md
@@ -1,9 +1,8 @@
# dstack event
-The `dstack event` command (an alias for `dstack event list`) allows you to view events.
-
-
+The `dstack event` command, an alias for `dstack event list`, allows you to view events.
+For more details, see [Events](../../../concepts/events.md).
## Usage
diff --git a/mkdocs.yml b/mkdocs.yml
index 5dcdd69999..c7afe30256 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -77,7 +77,8 @@ plugins:
- docs/concepts/gateways.md: How to manage gateways - enabling auto-scaling, rate limits, and custom domains
- docs/concepts/secrets.md: How to manage secrets - for API keys or other sensitive data
- docs/concepts/projects.md: How to manage projects - for managing separate teams
- - docs/concepts/metrics.md: How to manage gateways - enabling auto-scaling, rate limits, and custom domains
+ - docs/concepts/metrics.md: How to access metrics - for monitoring of runs/jobs/fleets, hardware usage (e.g. GPU util), etc
+ - docs/concepts/events.md: How to access events - aka audit log - for tracking state changes, resource creation/deletion, etc
Guides:
- docs/guides/server-deployment.md: Detailed guide on how to deploy the dstack server
- docs/guides/troubleshooting.md: Common issues and how to troubleshoot them
@@ -271,6 +272,7 @@ nav:
- Secrets: docs/concepts/secrets.md
- Projects: docs/concepts/projects.md
- Metrics: docs/concepts/metrics.md
+ - Events: docs/concepts/events.md
- Guides:
- Server deployment: docs/guides/server-deployment.md
- Troubleshooting: docs/guides/troubleshooting.md
From a1f0d58d1adae1da4a7cc6048251ef13c034a765 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Tue, 27 Jan 2026 21:21:32 +0100
Subject: [PATCH 032/640] [UX] Extend `dstack login` with interactive selection
of `url` and default project (#3492)
* [UX] Extend `dstack login` with interactive selection of `url` and default project
* Updated tests
* Addressing issues from the PR review (removed overly-agressibe catching of import and other exceptions when detecting if interactive menu is available). Plus, added handling KeyboardException in `dstack login`.
---
src/dstack/_internal/cli/commands/login.py | 165 ++++++-
src/dstack/_internal/cli/commands/project.py | 14 +-
src/dstack/_internal/cli/utils/common.py | 6 +-
.../_internal/cli/commands/test_login.py | 414 ++++++++++++++++--
4 files changed, 520 insertions(+), 79 deletions(-)
diff --git a/src/dstack/_internal/cli/commands/login.py b/src/dstack/_internal/cli/commands/login.py
index 0919494e63..8431a76c61 100644
--- a/src/dstack/_internal/cli/commands/login.py
+++ b/src/dstack/_internal/cli/commands/login.py
@@ -1,18 +1,34 @@
import argparse
import queue
+import sys
import threading
import urllib.parse
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
-from typing import Optional
+from typing import Any, Optional
+
+import questionary
+from rich.prompt import Prompt as RichPrompt
+from rich.text import Text
from dstack._internal.cli.commands import BaseCommand
+from dstack._internal.cli.commands.project import select_default_project
from dstack._internal.cli.utils.common import console, resolve_url
from dstack._internal.core.errors import ClientError, CLIError
from dstack._internal.core.models.users import UserWithCreds
+from dstack._internal.utils.logging import get_logger
from dstack.api._public.runs import ConfigManager
from dstack.api.server import APIClient
+logger = get_logger(__name__)
+
+is_project_menu_supported = sys.stdin.isatty()
+
+
+class UrlPrompt(RichPrompt):
+ def render_default(self, default: Any) -> Text:
+ return Text(f"({default})", style="bold orange1")
+
class LoginCommand(BaseCommand):
NAME = "login"
@@ -23,7 +39,7 @@ def _register(self):
self._parser.add_argument(
"--url",
help="The server URL, e.g. https://sky.dstack.ai",
- required=True,
+ required=not is_project_menu_supported,
)
self._parser.add_argument(
"-p",
@@ -33,10 +49,25 @@ def _register(self):
" Selected automatically if the server supports only one provider."
),
)
+ self._parser.add_argument(
+ "-y",
+ "--yes",
+ help="Don't ask for confirmation (e.g. set first project as default)",
+ action="store_true",
+ )
+ self._parser.add_argument(
+ "-n",
+ "--no",
+ help="Don't ask for confirmation (e.g. do not change default project)",
+ action="store_true",
+ )
def _command(self, args: argparse.Namespace):
super()._command(args)
- base_url = _normalize_url_or_error(args.url)
+ url = args.url
+ if url is None:
+ url = self._prompt_url()
+ base_url = _normalize_url_or_error(url)
api_client = APIClient(base_url=base_url)
provider = self._select_provider_or_error(api_client=api_client, provider=args.provider)
server = _LoginServer(api_client=api_client, provider=provider)
@@ -56,9 +87,9 @@ def _command(self, args: argparse.Namespace):
server.shutdown()
if user is None:
raise CLIError("CLI authentication failed")
- console.print(f"Logged in as [code]{user.username}[/].")
+ console.print(f"Logged in as [code]{user.username}[/]")
api_client = APIClient(base_url=base_url, token=user.creds.token)
- self._configure_projects(api_client=api_client, user=user)
+ self._configure_projects(api_client=api_client, user=user, args=args)
def _select_provider_or_error(self, api_client: APIClient, provider: Optional[str]) -> str:
providers = api_client.auth.list_providers()
@@ -67,6 +98,8 @@ def _select_provider_or_error(self, api_client: APIClient, provider: Optional[st
raise CLIError("No SSO providers configured on the server.")
if provider is None:
if len(available_providers) > 1:
+ if is_project_menu_supported:
+ return self._prompt_provider(available_providers)
raise CLIError(
"Specify -p/--provider to choose SSO provider"
f" Available providers: {', '.join(available_providers)}"
@@ -79,7 +112,37 @@ def _select_provider_or_error(self, api_client: APIClient, provider: Optional[st
)
return provider
- def _configure_projects(self, api_client: APIClient, user: UserWithCreds):
+ def _prompt_url(self) -> str:
+ try:
+ url = UrlPrompt.ask(
+ "Enter the server URL",
+ default="https://sky.dstack.ai",
+ console=console,
+ )
+ except KeyboardInterrupt:
+ console.print("\nCancelled by user")
+ raise SystemExit(1)
+ if url is None:
+ raise CLIError("URL is required")
+ return url
+
+ def _prompt_provider(self, available_providers: list[str]) -> str:
+ choices = [
+ questionary.Choice(title=provider, value=provider) for provider in available_providers
+ ]
+ selected_provider = questionary.select(
+ message="Select SSO provider:",
+ choices=choices,
+ qmark="",
+ instruction="(↑↓ Enter)",
+ ).ask()
+ if selected_provider is None:
+ raise SystemExit(1)
+ return selected_provider
+
+ def _configure_projects(
+ self, api_client: APIClient, user: UserWithCreds, args: argparse.Namespace
+ ):
projects = api_client.projects.list(include_not_joined=False)
if len(projects) == 0:
console.print(
@@ -89,30 +152,88 @@ def _configure_projects(self, api_client: APIClient, user: UserWithCreds):
return
config_manager = ConfigManager()
default_project = config_manager.get_project_config()
- new_default_project = None
- for i, project in enumerate(projects):
- set_as_default = (
- default_project is None
- and i == 0
- or default_project is not None
- and default_project.name == project.project_name
- )
- if set_as_default:
- new_default_project = project
+ for project in projects:
config_manager.configure_project(
name=project.project_name,
url=api_client.base_url,
token=user.creds.token,
- default=set_as_default,
+ default=False,
)
config_manager.save()
+ project_names = ", ".join(f"[code]{p.project_name}[/]" for p in projects)
console.print(
- f"Configured projects: {', '.join(f'[code]{p.project_name}[/]' for p in projects)}."
+ f"Added {project_names} project{'' if len(projects) == 1 else 's'} at {config_manager.config_filepath}"
)
- if new_default_project:
- console.print(
- f"Set project [code]{new_default_project.project_name}[/] as default project."
- )
+
+ project_configs = config_manager.list_project_configs()
+
+ if args.no:
+ return
+
+ if args.yes:
+ if len(projects) > 0:
+ first_project_from_server = projects[0]
+ first_project_config = next(
+ (
+ pc
+ for pc in project_configs
+ if pc.name == first_project_from_server.project_name
+ ),
+ None,
+ )
+ if first_project_config is not None:
+ config_manager.configure_project(
+ name=first_project_config.name,
+ url=first_project_config.url,
+ token=first_project_config.token,
+ default=True,
+ )
+ config_manager.save()
+ console.print(
+ f"Set [code]{first_project_config.name}[/] project as default at {config_manager.config_filepath}"
+ )
+ return
+
+ if len(project_configs) == 1 or not is_project_menu_supported:
+ selected_project = None
+ if len(project_configs) == 1:
+ selected_project = project_configs[0]
+ else:
+ for i, project in enumerate(projects):
+ set_as_default = (
+ default_project is None
+ and i == 0
+ or default_project is not None
+ and default_project.name == project.project_name
+ )
+ if set_as_default:
+ selected_project = next(
+ (pc for pc in project_configs if pc.name == project.project_name),
+ None,
+ )
+ break
+ if selected_project is not None:
+ config_manager.configure_project(
+ name=selected_project.name,
+ url=selected_project.url,
+ token=selected_project.token,
+ default=True,
+ )
+ config_manager.save()
+ console.print(
+ f"Set [code]{selected_project.name}[/] project as default at {config_manager.config_filepath}"
+ )
+ else:
+ console.print()
+ selected_project = select_default_project(project_configs, default_project)
+ if selected_project is not None:
+ config_manager.configure_project(
+ name=selected_project.name,
+ url=selected_project.url,
+ token=selected_project.token,
+ default=True,
+ )
+ config_manager.save()
class _BadRequestError(Exception):
diff --git a/src/dstack/_internal/cli/commands/project.py b/src/dstack/_internal/cli/commands/project.py
index db4a7a5eb9..5edd4e64ec 100644
--- a/src/dstack/_internal/cli/commands/project.py
+++ b/src/dstack/_internal/cli/commands/project.py
@@ -2,16 +2,10 @@
import sys
from typing import Any, Optional, Union
+import questionary
from requests import HTTPError
from rich.table import Table
-try:
- import questionary
-
- is_project_menu_supported = sys.stdin.isatty()
-except (ImportError, NotImplementedError, AttributeError):
- is_project_menu_supported = False
-
import dstack.api.server
from dstack._internal.cli.commands import BaseCommand
from dstack._internal.cli.utils.common import add_row_from_dict, confirm_ask, console
@@ -22,6 +16,8 @@
logger = get_logger(__name__)
+is_project_menu_supported = sys.stdin.isatty()
+
def select_default_project(
project_configs: list[ProjectConfig], default_project: Optional[ProjectConfig]
@@ -57,9 +53,9 @@ def select_default_project(
default_index = i
menu_entries.append((entry, i))
- choices = [questionary.Choice(title=entry, value=index) for entry, index in menu_entries] # pyright: ignore[reportPossiblyUnboundVariable]
+ choices = [questionary.Choice(title=entry, value=index) for entry, index in menu_entries]
default_value = default_index
- selected_index = questionary.select( # pyright: ignore[reportPossiblyUnboundVariable]
+ selected_index = questionary.select(
message="Select the default project:",
choices=choices,
default=default_value, # pyright: ignore[reportArgumentType]
diff --git a/src/dstack/_internal/cli/utils/common.py b/src/dstack/_internal/cli/utils/common.py
index d53b84567b..afbfb2d8cb 100644
--- a/src/dstack/_internal/cli/utils/common.py
+++ b/src/dstack/_internal/cli/utils/common.py
@@ -99,7 +99,11 @@ def configure_logging():
def confirm_ask(prompt, **kwargs) -> bool:
kwargs["console"] = console
- return Confirm.ask(prompt=prompt, **kwargs)
+ try:
+ return Confirm.ask(prompt=prompt, **kwargs)
+ except KeyboardInterrupt:
+ console.print("\nCancelled by user")
+ raise SystemExit(1)
def add_row_from_dict(table: Table, data: Dict[Union[str, int], Any], **kwargs):
diff --git a/src/tests/_internal/cli/commands/test_login.py b/src/tests/_internal/cli/commands/test_login.py
index bd7eba584f..4e0c27ae34 100644
--- a/src/tests/_internal/cli/commands/test_login.py
+++ b/src/tests/_internal/cli/commands/test_login.py
@@ -8,34 +8,72 @@
class TestLogin:
+ @staticmethod
+ def _setup_auth_mocks(api_client_mock, login_server_mock, user_token="token"):
+ """Set up common authentication mocks."""
+ api_client_mock.return_value.auth.list_providers.return_value = [
+ SimpleNamespace(name="github", enabled=True)
+ ]
+ api_client_mock.return_value.auth.authorize.return_value = SimpleNamespace(
+ authorization_url="http://auth_url"
+ )
+ user = SimpleNamespace(username="me", creds=SimpleNamespace(token=user_token))
+ login_server_mock.return_value.get_logged_in_user.return_value = user
+ return user
+
+ @staticmethod
+ def _setup_config_manager_with_state_tracking(
+ config_manager_mock, tmp_path: Path, project_configs: list[SimpleNamespace]
+ ):
+ """Set up ConfigManager mock with state tracking via side effects."""
+ config_manager_mock.return_value.config_filepath = tmp_path / "config.yml"
+ config_manager_mock.return_value.list_project_configs.return_value = project_configs
+
+ def configure_project_side_effect(name, url, token, default):
+ for pc in project_configs:
+ if pc.name == name:
+ pc.url = url
+ pc.token = token
+ if default:
+ for p in project_configs:
+ p.default = False
+ pc.default = default or pc.default
+ return
+
+ def get_project_config_side_effect(name=None):
+ if name is None:
+ for pc in project_configs:
+ if pc.default:
+ return pc
+ return None
+ for pc in project_configs:
+ if pc.name == name:
+ return pc
+ return None
+
+ config_manager_mock.return_value.configure_project.side_effect = (
+ configure_project_side_effect
+ )
+ config_manager_mock.return_value.get_project_config.side_effect = (
+ get_project_config_side_effect
+ )
+
def test_login_no_projects(self, capsys: CaptureFixture, tmp_path: Path):
with (
patch("dstack._internal.cli.commands.login.webbrowser") as webbrowser_mock,
- patch("dstack._internal.cli.commands.login.APIClient") as APIClientMock,
- patch("dstack._internal.cli.commands.login._LoginServer") as LoginServerMock,
+ patch("dstack._internal.cli.commands.login.APIClient") as api_client_mock,
+ patch("dstack._internal.cli.commands.login._LoginServer") as login_server_mock,
patch(
"dstack._internal.cli.commands.login._normalize_url_or_error"
- ) as _normalize_url_or_error_mock,
+ ) as normalize_url_mock,
):
webbrowser_mock.open.return_value = True
- _normalize_url_or_error_mock.return_value = "http://127.0.0.1:31313"
- APIClientMock.return_value.auth.list_providers.return_value = [
- SimpleNamespace(name="github", enabled=True)
- ]
- APIClientMock.return_value.auth.authorize.return_value = SimpleNamespace(
- authorization_url="http://auth_url"
- )
- APIClientMock.return_value.projects.list.return_value = []
- user = SimpleNamespace(username="me", creds=SimpleNamespace(token="token"))
- LoginServerMock.return_value.get_logged_in_user.return_value = user
+ normalize_url_mock.return_value = "http://127.0.0.1:31313"
+ self._setup_auth_mocks(api_client_mock, login_server_mock)
+ api_client_mock.return_value.projects.list.return_value = []
+
exit_code = run_dstack_cli(
- [
- "login",
- "--url",
- "http://127.0.0.1:31313",
- "--provider",
- "github",
- ],
+ ["login", "--url", "http://127.0.0.1:31313", "--provider", "github"],
home_dir=tmp_path,
)
@@ -43,53 +81,331 @@ def test_login_no_projects(self, capsys: CaptureFixture, tmp_path: Path):
assert capsys.readouterr().out.replace("\n", "") == (
"Your browser has been opened to log in with Github:"
"http://auth_url"
- "Logged in as me."
+ "Logged in as me"
"No projects configured. Create your own project via the UI or contact a project manager to add you to the project."
)
def test_login_configures_projects(self, capsys: CaptureFixture, tmp_path: Path):
with (
patch("dstack._internal.cli.commands.login.webbrowser") as webbrowser_mock,
- patch("dstack._internal.cli.commands.login.APIClient") as APIClientMock,
- patch("dstack._internal.cli.commands.login.ConfigManager") as ConfigManagerMock,
- patch("dstack._internal.cli.commands.login._LoginServer") as LoginServerMock,
+ patch("dstack._internal.cli.commands.login.APIClient") as api_client_mock,
+ patch("dstack._internal.cli.commands.login.ConfigManager") as config_manager_mock,
+ patch("dstack._internal.cli.commands.login._LoginServer") as login_server_mock,
patch(
"dstack._internal.cli.commands.login._normalize_url_or_error"
- ) as _normalize_url_or_error_mock,
+ ) as normalize_url_mock,
):
- _normalize_url_or_error_mock.return_value = "http://127.0.0.1:31313"
webbrowser_mock.open.return_value = True
- APIClientMock.return_value.auth.list_providers.return_value = [
- SimpleNamespace(name="github", enabled=True)
+ normalize_url_mock.return_value = "http://127.0.0.1:31313"
+ user = self._setup_auth_mocks(api_client_mock, login_server_mock)
+ api_client_mock.return_value.projects.list.return_value = [
+ SimpleNamespace(project_name="project1"),
+ SimpleNamespace(project_name="project2"),
]
- APIClientMock.return_value.auth.authorize.return_value = SimpleNamespace(
- authorization_url="http://auth_url"
+ api_client_mock.return_value.base_url = "http://127.0.0.1:31313"
+
+ project_configs = [
+ SimpleNamespace(
+ name="project1", url="http://127.0.0.1:31313", token="token", default=False
+ ),
+ SimpleNamespace(
+ name="project2", url="http://127.0.0.1:31313", token="token", default=False
+ ),
+ ]
+ config_manager_mock.return_value.get_project_config.return_value = None
+ self._setup_config_manager_with_state_tracking(
+ config_manager_mock, tmp_path, project_configs
+ )
+
+ exit_code = run_dstack_cli(
+ ["login", "--url", "http://127.0.0.1:31313", "--provider", "github"],
+ home_dir=tmp_path,
+ )
+
+ config_manager_mock.return_value.configure_project.assert_has_calls(
+ [
+ call(
+ name="project1",
+ url="http://127.0.0.1:31313",
+ token=user.creds.token,
+ default=False,
+ ),
+ call(
+ name="project2",
+ url="http://127.0.0.1:31313",
+ token=user.creds.token,
+ default=False,
+ ),
+ call(
+ name="project1", url="http://127.0.0.1:31313", token="token", default=True
+ ),
+ ]
)
- APIClientMock.return_value.projects.list.return_value = [
+ config_manager_mock.return_value.save.assert_called()
+ final_default = config_manager_mock.return_value.get_project_config()
+ assert final_default is not None
+ assert final_default.name == "project1"
+
+ assert exit_code == 0
+ assert capsys.readouterr().out.replace("\n", "") == (
+ "Your browser has been opened to log in with Github:"
+ "http://auth_url"
+ "Logged in as me"
+ f"Added project1, project2 projects at {tmp_path / 'config.yml'}"
+ f"Set project1 project as default at {tmp_path / 'config.yml'}"
+ )
+
+ def test_login_configures_projects_yes_sets_first_project_default(
+ self, capsys: CaptureFixture, tmp_path: Path
+ ):
+ with (
+ patch("dstack._internal.cli.commands.login.webbrowser") as webbrowser_mock,
+ patch("dstack._internal.cli.commands.login.APIClient") as api_client_mock,
+ patch("dstack._internal.cli.commands.login.ConfigManager") as config_manager_mock,
+ patch("dstack._internal.cli.commands.login._LoginServer") as login_server_mock,
+ patch(
+ "dstack._internal.cli.commands.login._normalize_url_or_error"
+ ) as normalize_url_mock,
+ ):
+ webbrowser_mock.open.return_value = True
+ normalize_url_mock.return_value = "http://127.0.0.1:31313"
+ user = self._setup_auth_mocks(api_client_mock, login_server_mock)
+ api_client_mock.return_value.projects.list.return_value = [
+ SimpleNamespace(project_name="project1"),
+ SimpleNamespace(project_name="project2"),
+ ]
+ api_client_mock.return_value.base_url = "http://127.0.0.1:31313"
+
+ project_configs = [
+ SimpleNamespace(
+ name="project1", url="http://127.0.0.1:31313", token="token", default=False
+ ),
+ SimpleNamespace(
+ name="project2", url="http://127.0.0.1:31313", token="token", default=True
+ ),
+ ]
+ self._setup_config_manager_with_state_tracking(
+ config_manager_mock, tmp_path, project_configs
+ )
+
+ exit_code = run_dstack_cli(
+ ["login", "--url", "http://127.0.0.1:31313", "--provider", "github", "--yes"],
+ home_dir=tmp_path,
+ )
+
+ config_manager_mock.return_value.configure_project.assert_has_calls(
+ [
+ call(
+ name="project1",
+ url="http://127.0.0.1:31313",
+ token=user.creds.token,
+ default=False,
+ ),
+ call(
+ name="project2",
+ url="http://127.0.0.1:31313",
+ token=user.creds.token,
+ default=False,
+ ),
+ call(
+ name="project1", url="http://127.0.0.1:31313", token="token", default=True
+ ),
+ ]
+ )
+ final_default = config_manager_mock.return_value.get_project_config()
+ assert final_default is not None
+ assert final_default.name == "project1"
+
+ assert exit_code == 0
+ assert capsys.readouterr().out.replace("\n", "") == (
+ "Your browser has been opened to log in with Github:"
+ "http://auth_url"
+ "Logged in as me"
+ f"Added project1, project2 projects at {tmp_path / 'config.yml'}"
+ f"Set project1 project as default at {tmp_path / 'config.yml'}"
+ )
+
+ def test_login_configures_projects_no_does_not_change_default(
+ self, capsys: CaptureFixture, tmp_path: Path
+ ):
+ with (
+ patch("dstack._internal.cli.commands.login.webbrowser") as webbrowser_mock,
+ patch("dstack._internal.cli.commands.login.APIClient") as api_client_mock,
+ patch("dstack._internal.cli.commands.login.ConfigManager") as config_manager_mock,
+ patch("dstack._internal.cli.commands.login._LoginServer") as login_server_mock,
+ patch(
+ "dstack._internal.cli.commands.login._normalize_url_or_error"
+ ) as normalize_url_mock,
+ ):
+ webbrowser_mock.open.return_value = True
+ normalize_url_mock.return_value = "http://127.0.0.1:31313"
+ user = self._setup_auth_mocks(api_client_mock, login_server_mock)
+ api_client_mock.return_value.projects.list.return_value = [
SimpleNamespace(project_name="project1"),
SimpleNamespace(project_name="project2"),
]
- APIClientMock.return_value.base_url = "http://127.0.0.1:31313"
- ConfigManagerMock.return_value.get_project_config.return_value = None
- user = SimpleNamespace(username="me", creds=SimpleNamespace(token="token"))
- LoginServerMock.return_value.get_logged_in_user.return_value = user
+ api_client_mock.return_value.base_url = "http://127.0.0.1:31313"
+
+ project_configs = [
+ SimpleNamespace(
+ name="project1", url="http://127.0.0.1:31313", token="token", default=False
+ ),
+ SimpleNamespace(
+ name="project2", url="http://127.0.0.1:31313", token="token", default=True
+ ),
+ ]
+ self._setup_config_manager_with_state_tracking(
+ config_manager_mock, tmp_path, project_configs
+ )
+
exit_code = run_dstack_cli(
+ ["login", "--url", "http://127.0.0.1:31313", "--provider", "github", "--no"],
+ home_dir=tmp_path,
+ )
+
+ config_manager_mock.return_value.configure_project.assert_has_calls(
[
- "login",
- "--url",
- "http://127.0.0.1:31313",
- "--provider",
- "github",
- ],
+ call(
+ name="project1",
+ url="http://127.0.0.1:31313",
+ token=user.creds.token,
+ default=False,
+ ),
+ call(
+ name="project2",
+ url="http://127.0.0.1:31313",
+ token=user.creds.token,
+ default=False,
+ ),
+ ]
+ )
+ assert (
+ call(name="project1", url="http://127.0.0.1:31313", token="token", default=True)
+ not in config_manager_mock.return_value.configure_project.mock_calls
+ )
+ final_default = config_manager_mock.return_value.get_project_config()
+ assert final_default is not None
+ assert final_default.name == "project2"
+
+ assert exit_code == 0
+ assert capsys.readouterr().out.replace("\n", "") == (
+ "Your browser has been opened to log in with Github:"
+ "http://auth_url"
+ "Logged in as me"
+ f"Added project1, project2 projects at {tmp_path / 'config.yml'}"
+ )
+
+ def test_login_single_project_auto_default(self, capsys: CaptureFixture, tmp_path: Path):
+ with (
+ patch("dstack._internal.cli.commands.login.webbrowser") as webbrowser_mock,
+ patch("dstack._internal.cli.commands.login.APIClient") as api_client_mock,
+ patch("dstack._internal.cli.commands.login.ConfigManager") as config_manager_mock,
+ patch("dstack._internal.cli.commands.login._LoginServer") as login_server_mock,
+ patch(
+ "dstack._internal.cli.commands.login._normalize_url_or_error"
+ ) as normalize_url_mock,
+ ):
+ webbrowser_mock.open.return_value = True
+ normalize_url_mock.return_value = "http://127.0.0.1:31313"
+ user = self._setup_auth_mocks(api_client_mock, login_server_mock)
+ api_client_mock.return_value.projects.list.return_value = [
+ SimpleNamespace(project_name="project1"),
+ ]
+ api_client_mock.return_value.base_url = "http://127.0.0.1:31313"
+
+ project_configs = [
+ SimpleNamespace(
+ name="project1", url="http://127.0.0.1:31313", token="token", default=False
+ ),
+ ]
+ config_manager_mock.return_value.get_project_config.return_value = None
+ self._setup_config_manager_with_state_tracking(
+ config_manager_mock, tmp_path, project_configs
+ )
+
+ exit_code = run_dstack_cli(
+ ["login", "--url", "http://127.0.0.1:31313", "--provider", "github"],
home_dir=tmp_path,
)
- ConfigManagerMock.return_value.configure_project.assert_has_calls(
+
+ config_manager_mock.return_value.configure_project.assert_has_calls(
[
call(
name="project1",
url="http://127.0.0.1:31313",
token=user.creds.token,
- default=True,
+ default=False,
+ ),
+ call(
+ name="project1", url="http://127.0.0.1:31313", token="token", default=True
+ ),
+ ]
+ )
+ final_default = config_manager_mock.return_value.get_project_config()
+ assert final_default is not None
+ assert final_default.name == "project1"
+
+ assert exit_code == 0
+ assert capsys.readouterr().out.replace("\n", "") == (
+ "Your browser has been opened to log in with Github:"
+ "http://auth_url"
+ "Logged in as me"
+ f"Added project1 project at {tmp_path / 'config.yml'}"
+ f"Set project1 project as default at {tmp_path / 'config.yml'}"
+ )
+
+ def test_login_interactive_prompts_for_default_project(
+ self, capsys: CaptureFixture, tmp_path: Path
+ ):
+ with (
+ patch("dstack._internal.cli.commands.login.webbrowser") as webbrowser_mock,
+ patch("dstack._internal.cli.commands.login.APIClient") as api_client_mock,
+ patch("dstack._internal.cli.commands.login.ConfigManager") as config_manager_mock,
+ patch("dstack._internal.cli.commands.login._LoginServer") as login_server_mock,
+ patch(
+ "dstack._internal.cli.commands.login._normalize_url_or_error"
+ ) as normalize_url_mock,
+ patch(
+ "dstack._internal.cli.commands.login.select_default_project"
+ ) as select_default_project_mock,
+ patch("dstack._internal.cli.commands.login.is_project_menu_supported", True),
+ ):
+ webbrowser_mock.open.return_value = True
+ normalize_url_mock.return_value = "http://127.0.0.1:31313"
+ user = self._setup_auth_mocks(api_client_mock, login_server_mock)
+ api_client_mock.return_value.projects.list.return_value = [
+ SimpleNamespace(project_name="project1"),
+ SimpleNamespace(project_name="project2"),
+ ]
+ api_client_mock.return_value.base_url = "http://127.0.0.1:31313"
+
+ project_configs = [
+ SimpleNamespace(
+ name="project1", url="http://127.0.0.1:31313", token="token", default=False
+ ),
+ SimpleNamespace(
+ name="project2", url="http://127.0.0.1:31313", token="token", default=False
+ ),
+ ]
+ config_manager_mock.return_value.get_project_config.return_value = None
+ self._setup_config_manager_with_state_tracking(
+ config_manager_mock, tmp_path, project_configs
+ )
+ select_default_project_mock.return_value = project_configs[1]
+
+ exit_code = run_dstack_cli(
+ ["login", "--url", "http://127.0.0.1:31313", "--provider", "github"],
+ home_dir=tmp_path,
+ )
+
+ select_default_project_mock.assert_called_once()
+ config_manager_mock.return_value.configure_project.assert_has_calls(
+ [
+ call(
+ name="project1",
+ url="http://127.0.0.1:31313",
+ token=user.creds.token,
+ default=False,
),
call(
name="project2",
@@ -97,15 +413,19 @@ def test_login_configures_projects(self, capsys: CaptureFixture, tmp_path: Path)
token=user.creds.token,
default=False,
),
+ call(
+ name="project2", url="http://127.0.0.1:31313", token="token", default=True
+ ),
]
)
- ConfigManagerMock.return_value.save.assert_called()
+ final_default = config_manager_mock.return_value.get_project_config()
+ assert final_default is not None
+ assert final_default.name == "project2"
assert exit_code == 0
assert capsys.readouterr().out.replace("\n", "") == (
"Your browser has been opened to log in with Github:"
"http://auth_url"
- "Logged in as me."
- "Configured projects: project1, project2."
- "Set project project1 as default project."
+ "Logged in as me"
+ f"Added project1, project2 projects at {tmp_path / 'config.yml'}"
)
From bef08a593979e2b68ac3e6125061c5f85611dcfb Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 28 Jan 2026 09:30:58 +0000
Subject: [PATCH 033/640] Add secret lifecycle events (#3505)
- Secret created
- Secret updated
- Secret deleted
---
.../_internal/server/routers/secrets.py | 6 +-
.../_internal/server/services/secrets.py | 127 ++++++++++++------
.../_internal/server/routers/test_secrets.py | 32 +++++
3 files changed, 120 insertions(+), 45 deletions(-)
diff --git a/src/dstack/_internal/server/routers/secrets.py b/src/dstack/_internal/server/routers/secrets.py
index c19f15bccc..30cbdc60c7 100644
--- a/src/dstack/_internal/server/routers/secrets.py
+++ b/src/dstack/_internal/server/routers/secrets.py
@@ -59,13 +59,14 @@ async def create_or_update_secret(
session: AsyncSession = Depends(get_session),
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
):
- _, project = user_project
+ user, project = user_project
return CustomORJSONResponse(
await secrets_services.create_or_update_secret(
session=session,
project=project,
name=body.name,
value=body.value,
+ user=user,
)
)
@@ -76,9 +77,10 @@ async def delete_secrets(
session: AsyncSession = Depends(get_session),
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
):
- _, project = user_project
+ user, project = user_project
await secrets_services.delete_secrets(
session=session,
project=project,
names=body.secrets_names,
+ user=user,
)
diff --git a/src/dstack/_internal/server/services/secrets.py b/src/dstack/_internal/server/services/secrets.py
index 1cea57f655..ed12576256 100644
--- a/src/dstack/_internal/server/services/secrets.py
+++ b/src/dstack/_internal/server/services/secrets.py
@@ -1,8 +1,11 @@
import re
+import uuid
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
from typing import Dict, List, Optional
import sqlalchemy.exc
-from sqlalchemy import delete, select, update
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from dstack._internal.core.errors import (
@@ -11,11 +14,10 @@
ServerClientError,
)
from dstack._internal.core.models.secrets import Secret
-from dstack._internal.server.models import DecryptedString, ProjectModel, SecretModel
-from dstack._internal.utils.logging import get_logger
-
-logger = get_logger(__name__)
-
+from dstack._internal.server.db import get_db
+from dstack._internal.server.models import DecryptedString, ProjectModel, SecretModel, UserModel
+from dstack._internal.server.services import events
+from dstack._internal.server.services.locking import get_locker
_SECRET_NAME_REGEX = "^[A-Za-z0-9-_]{1,200}$"
_SECRET_VALUE_MAX_LENGTH = 5000
@@ -57,6 +59,7 @@ async def create_or_update_secret(
project: ProjectModel,
name: str,
value: str,
+ user: UserModel,
) -> Secret:
_validate_secret(name=name, value=value)
try:
@@ -65,6 +68,7 @@ async def create_or_update_secret(
project=project,
name=name,
value=value,
+ user=user,
)
except ResourceExistsError:
secret_model = await update_secret(
@@ -72,6 +76,7 @@ async def create_or_update_secret(
project=project,
name=name,
value=value,
+ user=user,
)
return secret_model_to_secret(secret_model, include_value=True)
@@ -80,26 +85,24 @@ async def delete_secrets(
session: AsyncSession,
project: ProjectModel,
names: List[str],
+ user: UserModel,
):
- existing_secrets_query = await session.execute(
- select(SecretModel).where(
- SecretModel.project_id == project.id,
- SecretModel.name.in_(names),
- )
- )
- existing_names = [s.name for s in existing_secrets_query.scalars().all()]
- missing_names = set(names) - set(existing_names)
- if missing_names:
- raise ResourceNotExistsError(f"Secrets not found: {', '.join(missing_names)}")
-
- await session.execute(
- delete(SecretModel).where(
- SecretModel.project_id == project.id,
- SecretModel.name.in_(names),
- )
- )
- await session.commit()
- logger.info("Deleted secrets %s in project %s", names, project.name)
+ async with get_project_secret_models_by_name_for_update(
+ session=session, project=project, names=names
+ ) as secret_models:
+ existing_names = [s.name for s in secret_models]
+ missing_names = set(names) - set(existing_names)
+ if missing_names:
+ raise ResourceNotExistsError(f"Secrets not found: {', '.join(missing_names)}")
+ for secret_model in secret_models:
+ await session.delete(secret_model)
+ events.emit(
+ session,
+ "Secret deleted",
+ actor=events.UserActor.from_user(user),
+ targets=[events.Target.from_model(secret_model)],
+ )
+ await session.commit()
def secret_model_to_secret(secret_model: SecretModel, include_value: bool = False) -> Secret:
@@ -142,13 +145,47 @@ async def get_project_secret_model_by_name(
return res.scalar_one_or_none()
+@asynccontextmanager
+async def get_project_secret_models_by_name_for_update(
+ session: AsyncSession, project: ProjectModel, names: list[str]
+) -> AsyncGenerator[list[SecretModel], None]:
+ """
+ Fetch secrets from the database and lock them for update.
+
+ **NOTE**: commit changes to the database before exiting from this context manager,
+ so that in-memory locks are only released after commit.
+ """
+ filters = [
+ SecretModel.project_id == project.id,
+ SecretModel.name.in_(names),
+ ]
+ res = await session.execute(select(SecretModel.id).where(*filters))
+ secret_ids = res.scalars().all()
+ if not secret_ids:
+ yield []
+ else:
+ async with get_locker(get_db().dialect_name).lock_ctx(
+ SecretModel.__tablename__, sorted(secret_ids)
+ ):
+ # Refetch after lock
+ res = await session.execute(
+ select(SecretModel)
+ .where(SecretModel.id.in_(secret_ids), *filters)
+ .with_for_update(key_share=True)
+ .order_by(SecretModel.id) # take locks in order
+ )
+ yield list(res.scalars().all())
+
+
async def create_secret(
session: AsyncSession,
project: ProjectModel,
name: str,
value: str,
+ user: UserModel,
) -> SecretModel:
secret_model = SecretModel(
+ id=uuid.uuid4(),
project_id=project.id,
name=name,
value=DecryptedString(plaintext=value),
@@ -156,6 +193,12 @@ async def create_secret(
try:
async with session.begin_nested():
session.add(secret_model)
+ events.emit(
+ session,
+ "Secret created",
+ actor=events.UserActor.from_user(user),
+ targets=[events.Target.from_model(secret_model)],
+ )
except sqlalchemy.exc.IntegrityError:
raise ResourceExistsError()
await session.commit()
@@ -167,25 +210,23 @@ async def update_secret(
project: ProjectModel,
name: str,
value: str,
+ user: UserModel,
) -> SecretModel:
- await session.execute(
- update(SecretModel)
- .where(
- SecretModel.project_id == project.id,
- SecretModel.name == name,
- )
- .values(
- value=DecryptedString(plaintext=value),
- )
- )
- await session.commit()
- secret_model = await get_project_secret_model_by_name(
- session=session,
- project=project,
- name=name,
- )
- if secret_model is None:
- raise ResourceNotExistsError()
+ async with get_project_secret_models_by_name_for_update(
+ session=session, project=project, names=[name]
+ ) as secret_models:
+ if not secret_models:
+ raise ResourceNotExistsError()
+ secret_model = secret_models[0]
+ if secret_model.value.get_plaintext_or_error() != value:
+ secret_model.value = DecryptedString(plaintext=value)
+ events.emit(
+ session,
+ "Secret updated",
+ actor=events.UserActor.from_user(user),
+ targets=[events.Target.from_model(secret_model)],
+ )
+ await session.commit()
return secret_model
diff --git a/src/tests/_internal/server/routers/test_secrets.py b/src/tests/_internal/server/routers/test_secrets.py
index a2279c770c..302591b881 100644
--- a/src/tests/_internal/server/routers/test_secrets.py
+++ b/src/tests/_internal/server/routers/test_secrets.py
@@ -11,6 +11,7 @@
create_secret,
create_user,
get_auth_headers,
+ list_events,
)
@@ -145,6 +146,9 @@ async def test_creates_secret(self, test_db, session: AsyncSession, client: Asyn
res = await session.execute(select(SecretModel))
secret_model = res.scalar()
assert secret_model is not None
+ events = await list_events(session)
+ assert len(events) == 1
+ assert events[0].message == "Secret created"
@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
@@ -165,6 +169,29 @@ async def test_updates_secret(self, test_db, session: AsyncSession, client: Asyn
assert response.status_code == 200
await session.refresh(secret)
assert secret.value.get_plaintext_or_error() == "new_value"
+ events = await list_events(session)
+ assert len(events) == 1
+ assert events[0].message == "Secret updated"
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_no_event_if_value_unchanged(
+ self, test_db, session: AsyncSession, client: AsyncClient
+ ):
+ user = await create_user(session=session, global_role=GlobalRole.USER)
+ project = await create_project(session=session, owner=user)
+ await add_project_member(
+ session=session, project=project, user=user, project_role=ProjectRole.ADMIN
+ )
+ await create_secret(session=session, project=project, name="secret1", value="value")
+ response = await client.post(
+ f"/api/project/{project.name}/secrets/create_or_update",
+ headers=get_auth_headers(user.token),
+ json={"name": "secret1", "value": "value"},
+ )
+ assert response.status_code == 200
+ events = await list_events(session)
+ assert len(events) == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
@@ -253,6 +280,11 @@ async def test_deletes_secrets(self, test_db, session: AsyncSession, client: Asy
assert len(secrets) == 1
assert secrets[0].name == "secret2"
+ # Verify event was emitted
+ events = await list_events(session)
+ assert len(events) == 1
+ assert events[0].message == "Secret deleted"
+
@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
async def test_delete_nonexistent_secret_raises_error(
From b7f637b1885e120b292a300f499c79c6f49541d3 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 28 Jan 2026 09:33:15 +0000
Subject: [PATCH 034/640] Fix apply plan compatibility with old servers (#3507)
---
src/dstack/_internal/core/compatibility/runs.py | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/dstack/_internal/core/compatibility/runs.py b/src/dstack/_internal/core/compatibility/runs.py
index 5bec348d67..f1b7dc5222 100644
--- a/src/dstack/_internal/core/compatibility/runs.py
+++ b/src/dstack/_internal/core/compatibility/runs.py
@@ -1,7 +1,12 @@
from typing import Optional
from dstack._internal.core.models.common import IncludeExcludeDictType, IncludeExcludeSetType
-from dstack._internal.core.models.runs import ApplyRunPlanInput, JobSpec, RunSpec
+from dstack._internal.core.models.runs import (
+ DEFAULT_REPLICA_GROUP_NAME,
+ ApplyRunPlanInput,
+ JobSpec,
+ RunSpec,
+)
from dstack._internal.server.schemas.runs import GetRunPlanRequest, ListRunsRequest
@@ -23,7 +28,13 @@ def get_apply_plan_excludes(plan: ApplyRunPlanInput) -> Optional[IncludeExcludeD
current_resource = plan.current_resource
if current_resource is not None:
current_resource_excludes: IncludeExcludeDictType = {}
+ apply_plan_excludes["current_resource"] = current_resource_excludes
current_resource_excludes["run_spec"] = get_run_spec_excludes(current_resource.run_spec)
+ current_resource_excludes["jobs"] = {
+ "__all__": {
+ "job_spec": get_job_spec_excludes([job.job_spec for job in current_resource.jobs]),
+ }
+ }
return {"plan": apply_plan_excludes}
@@ -70,4 +81,6 @@ def get_job_spec_excludes(job_specs: list[JobSpec]) -> IncludeExcludeDictType:
clients backward-compatibility with older servers.
"""
spec_excludes: IncludeExcludeDictType = {}
+ if all(s.replica_group == DEFAULT_REPLICA_GROUP_NAME for s in job_specs):
+ spec_excludes["replica_group"] = True
return spec_excludes
From 6da1554063294252261db0e6b2e42af550fb2d0e Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Wed, 28 Jan 2026 14:26:10 +0100
Subject: [PATCH 035/640] [UI] Minor tweaks (#3508)
---
.../AppLayout/TutorialPanel/constants.tsx | 10 +++++-----
.../src/layouts/AppLayout/TutorialPanel/hooks.ts | 16 ++++++++--------
frontend/src/layouts/AppLayout/index.tsx | 2 +-
frontend/src/locale/en.json | 2 +-
4 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx b/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx
index 7d4bb378b2..4ee909c0cf 100644
--- a/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx
+++ b/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx
@@ -8,7 +8,7 @@ import { Box } from 'components';
export const tutorialPanelI18nStrings: TutorialPanelProps.I18nStrings = {
labelsTaskStatus: { pending: 'Pending', 'in-progress': 'In progress', success: 'Success' },
loadingText: 'Loading',
- tutorialListTitle: 'Take a tour',
+ tutorialListTitle: '',
tutorialListDescription: 'Follow the tutorials below to get up to speed with dstack Sky.',
tutorialListDownloadLinkText: 'Download PDF version',
tutorialCompletedText: 'Completed',
@@ -50,7 +50,7 @@ export enum HotspotIds {
export const BILLING_TUTORIAL: TutorialPanelProps.Tutorial = {
completed: false,
- title: 'Set up billing',
+ title: 'Billing',
description: (
<>
@@ -80,7 +80,7 @@ export const BILLING_TUTORIAL: TutorialPanelProps.Tutorial = {
export const CONFIGURE_CLI_TUTORIAL: TutorialPanelProps.Tutorial = {
completed: false,
- title: 'Set up the CLI',
+ title: 'CLI',
prerequisitesAlert: 'Please, create a project before set up the CLI',
description: (
<>
@@ -111,7 +111,7 @@ export const CONFIGURE_CLI_TUTORIAL: TutorialPanelProps.Tutorial = {
export const CREATE_FIRST_PROJECT: TutorialPanelProps.Tutorial = {
completed: false,
- title: 'Create a project',
+ title: 'Project',
description: (
<>
@@ -136,7 +136,7 @@ export const CREATE_FIRST_PROJECT: TutorialPanelProps.Tutorial = {
export const JOIN_DISCORD_TUTORIAL: TutorialPanelProps.Tutorial = {
completed: false,
- title: 'Community',
+ title: 'Discord',
description: (
<>
diff --git a/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts b/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts
index d3a465fcb5..74d166ba8d 100644
--- a/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts
+++ b/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts
@@ -167,14 +167,6 @@ export const useTutorials = () => {
prerequisitesNeeded: !createProjectCompleted,
},
- {
- ...BILLING_TUTORIAL,
- id: 4,
- completed: billingCompleted,
- startCallback: startBillingTutorial,
- finishCallback: finishBillingTutorial,
- },
-
{
...QUICKSTART_TUTORIAL,
id: 5,
@@ -190,6 +182,14 @@ export const useTutorials = () => {
completed: discordCompleted,
startCallback: startDiscordTutorial,
},
+
+ {
+ ...BILLING_TUTORIAL,
+ id: 4,
+ completed: billingCompleted,
+ startCallback: startBillingTutorial,
+ finishCallback: finishBillingTutorial,
+ },
];
}, [
billingUrl,
diff --git a/frontend/src/layouts/AppLayout/index.tsx b/frontend/src/layouts/AppLayout/index.tsx
index d417779d41..3dd4db470b 100644
--- a/frontend/src/layouts/AppLayout/index.tsx
+++ b/frontend/src/layouts/AppLayout/index.tsx
@@ -183,7 +183,7 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
},
process.env.UI_VERSION === 'sky' && {
type: 'button',
- iconName: 'suggestions',
+ iconName: 'support',
title: t('common.tutorial_other'),
onClick: toggleTutorialPanel,
},
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index c821abf317..342ce0cdd7 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -46,7 +46,7 @@
"continue": "Continue",
"select_visible_columns": "Select visible columns",
"tutorial": "Tutorials",
- "tutorial_other": "Tour",
+ "tutorial_other": "Take a tour",
"docs": "Docs",
"discord": "Discord",
"danger_zone": "Danger Zone",
From 7ba2f3c17155f0082a4a3706361d23d6781744da Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 28 Jan 2026 13:30:59 +0000
Subject: [PATCH 036/640] Fix `dstack event` compat. with older servers (#3509)
Fixes this error:
```
$ dstack event
Server validation error:
{'detail': [{'loc': ['body', 'target_gateways'],
'msg': 'extra fields not permitted',
'type': 'value_error.extra'},
{'loc': ['body', 'target_secrets'],
'msg': 'extra fields not permitted',
'type': 'value_error.extra'},
{'loc': ['body', 'target_volumes'],
'msg': 'extra fields not permitted',
'type': 'value_error.extra'}]}
```
---
src/dstack/_internal/core/compatibility/events.py | 13 +++++++++++++
src/dstack/api/server/_events.py | 5 ++++-
2 files changed, 17 insertions(+), 1 deletion(-)
create mode 100644 src/dstack/_internal/core/compatibility/events.py
diff --git a/src/dstack/_internal/core/compatibility/events.py b/src/dstack/_internal/core/compatibility/events.py
new file mode 100644
index 0000000000..b28db11587
--- /dev/null
+++ b/src/dstack/_internal/core/compatibility/events.py
@@ -0,0 +1,13 @@
+from dstack._internal.core.models.common import IncludeExcludeDictType
+from dstack._internal.server.schemas.events import ListEventsRequest
+
+
+def get_list_events_excludes(request: ListEventsRequest) -> IncludeExcludeDictType:
+ list_gpus_excludes: IncludeExcludeDictType = {}
+ if request.target_volumes is None:
+ list_gpus_excludes["target_volumes"] = True
+ if request.target_gateways is None:
+ list_gpus_excludes["target_gateways"] = True
+ if request.target_secrets is None:
+ list_gpus_excludes["target_secrets"] = True
+ return list_gpus_excludes
diff --git a/src/dstack/api/server/_events.py b/src/dstack/api/server/_events.py
index 5aff9c9d21..22cd8893cd 100644
--- a/src/dstack/api/server/_events.py
+++ b/src/dstack/api/server/_events.py
@@ -4,6 +4,7 @@
from pydantic import parse_obj_as
+from dstack._internal.core.compatibility.events import get_list_events_excludes
from dstack._internal.core.models.events import Event, EventTargetType
from dstack._internal.server.schemas.events import LIST_EVENTS_DEFAULT_LIMIT, ListEventsRequest
from dstack.api.server._group import APIClientGroup
@@ -57,5 +58,7 @@ def list(
limit=limit,
ascending=ascending,
)
- resp = self._request("/api/events/list", body=req.json())
+ resp = self._request(
+ "/api/events/list", body=req.json(exclude=get_list_events_excludes(req))
+ )
return parse_obj_as(list[Event.__response__], resp.json())
From 763092d92074d22888ae7ab91192ddeb8085c249 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 28 Jan 2026 13:31:12 +0000
Subject: [PATCH 037/640] Fix scaling during update to replica groups (#3510)
This fix prevents the number of replicas from
dropping to `replicas.min` for all existing
services during a server update to 0.20.7.
---
.../server/services/services/__init__.py | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/src/dstack/_internal/server/services/services/__init__.py b/src/dstack/_internal/server/services/services/__init__.py
index 1f7dcde79d..45bb1fe0fe 100644
--- a/src/dstack/_internal/server/services/services/__init__.py
+++ b/src/dstack/_internal/server/services/services/__init__.py
@@ -19,7 +19,11 @@
ServerClientError,
SSHError,
)
-from dstack._internal.core.models.configurations import SERVICE_HTTPS_DEFAULT, ServiceConfiguration
+from dstack._internal.core.models.configurations import (
+ DEFAULT_REPLICA_GROUP_NAME,
+ SERVICE_HTTPS_DEFAULT,
+ ServiceConfiguration,
+)
from dstack._internal.core.models.gateways import GatewayConfiguration, GatewayStatus
from dstack._internal.core.models.instances import SSHConnectionParams
from dstack._internal.core.models.runs import JobSpec, Run, RunSpec, ServiceModelSpec, ServiceSpec
@@ -318,8 +322,18 @@ async def update_service_desired_replica_count(
prev_counts = (
json.loads(run_model.desired_replica_counts) if run_model.desired_replica_counts else {}
)
+ if (
+ prev_counts == {}
+ and len(replica_groups) == 1
+ and replica_groups[0].name == DEFAULT_REPLICA_GROUP_NAME
+ ):
+ # Special case to avoid dropping the replica count to group.count.min
+ # when a 0.20.7+ server first processes a service created by a pre-0.20.7 server.
+ # TODO: remove once most users upgrade to 0.20.7+.
+ prev_counts = {DEFAULT_REPLICA_GROUP_NAME: run_model.desired_replica_count}
for group in replica_groups:
scaler = get_service_scaler(group.count, group.scaling)
+ assert group.name is not None, "Group name is always set"
group_desired = scaler.get_desired_count(
current_desired_count=prev_counts.get(group.name, group.count.min or 0),
stats=stats,
From bb0278886dd4cb662b4c60fdc52ab4879779e09d Mon Sep 17 00:00:00 2001
From: Bihan Rana
Date: Wed, 28 Jan 2026 22:39:07 +0545
Subject: [PATCH 038/640] [Docs] Replica groups (#3511)
* Add Replica Groups Docs
* Minor edits
---------
Co-authored-by: Bihan Rana
Co-authored-by: peterschmidt85
---
docs/docs/concepts/services.md | 51 ++++++++++++++++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/docs/docs/concepts/services.md b/docs/docs/concepts/services.md
index 745f78e3f0..0f6bf07bb8 100644
--- a/docs/docs/concepts/services.md
+++ b/docs/docs/concepts/services.md
@@ -164,6 +164,57 @@ Setting the minimum number of replicas to `0` allows the service to scale down t
> The `scaling` property requires creating a [gateway](gateways.md).
+??? info "Replica groups"
+ A service can include multiple replica groups. Each group can define its own `commands`, `resources` requirements, and `scaling` rules.
+
+
+
+ ```yaml
+ type: service
+ name: llama-8b-service
+
+ image: lmsysorg/sglang:latest
+ env:
+ - MODEL_ID=deepseek-ai/DeepSeek-R1-Distill-Llama-8B
+
+ replicas:
+ - count: 1..2
+ scaling:
+ metric: rps
+ target: 10
+ commands:
+ - |
+ python -m sglang.launch_server \
+ --model-path $MODEL_ID \
+ --port 8000 \
+ --trust-remote-code
+ resources:
+ gpu: 48GB
+
+ - count: 1..4
+ scaling:
+ metric: rps
+ target: 5
+ commands:
+ - |
+ python -m sglang.launch_server \
+ --model-path $MODEL_ID \
+ --port 8000 \
+ --trust-remote-code
+ resources:
+ gpu: 24GB
+
+ port: 8000
+ model: deepseek-ai/DeepSeek-R1-Distill-Llama-8B
+ ```
+
+
+
+ > Properties such as `regions`, `port`, `image`, `env` and some other cannot be configured per replica group. This support is coming soon.
+
+??? info "Disaggregated serving"
+ Native support for disaggregated prefill and decode, allowing both worker types to run within a single service, is coming soon.
+
### Model
If the service is running a chat model with an OpenAI-compatible interface,
From 0d58216d0ea088df7281130a4d45ed9996972040 Mon Sep 17 00:00:00 2001
From: Oleg Vavilov
Date: Wed, 28 Jan 2026 22:51:40 +0300
Subject: [PATCH 039/640] Showed counters for Project and User List
---
frontend/src/pages/Events/List/hooks/useFilters.ts | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts
index e6a97c71e8..d9457f9064 100644
--- a/frontend/src/pages/Events/List/hooks/useFilters.ts
+++ b/frontend/src/pages/Events/List/hooks/useFilters.ts
@@ -148,22 +148,22 @@ export const useFilters = () => {
{
key: filterKeys.TARGET_FLEETS,
operators: ['='],
- propertyLabel: 'Target fleets',
+ propertyLabel: 'Target fleet IDs',
},
{
key: filterKeys.TARGET_INSTANCES,
operators: ['='],
- propertyLabel: 'Target instances',
+ propertyLabel: 'Target instance IDs',
},
{
key: filterKeys.TARGET_RUNS,
operators: ['='],
- propertyLabel: 'Target runs',
+ propertyLabel: 'Target run IDs',
},
{
key: filterKeys.TARGET_JOBS,
operators: ['='],
- propertyLabel: 'Target jobs',
+ propertyLabel: 'Target job IDs',
},
{
key: filterKeys.TARGET_VOLUMES,
@@ -191,13 +191,13 @@ export const useFilters = () => {
{
key: filterKeys.WITHIN_FLEETS,
operators: ['='],
- propertyLabel: 'Within fleets',
+ propertyLabel: 'Within fleet IDs',
},
{
key: filterKeys.WITHIN_RUNS,
operators: ['='],
- propertyLabel: 'Within runs',
+ propertyLabel: 'Within run IDs',
},
{
From 916b94e3388b74614443cbc2043508e53eddbe25 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Thu, 29 Jan 2026 09:42:00 +0100
Subject: [PATCH 040/640] [Docs] Added `Spot policy` (#3512)
---
docs/docs/concepts/fleets.md | 32 +++++++++++++++++++++++++++++---
1 file changed, 29 insertions(+), 3 deletions(-)
diff --git a/docs/docs/concepts/fleets.md b/docs/docs/concepts/fleets.md
index 4def218456..dad06cb80a 100644
--- a/docs/docs/concepts/fleets.md
+++ b/docs/docs/concepts/fleets.md
@@ -281,12 +281,38 @@ Pre-provisioning is supported only for [VM-based backends](backends.md#vm-based)
Backend fleets allow you to specify the resource requirements for the instances to be provisioned. The `resources` property syntax is the same as for [run configurations](dev-environments.md#resources).
-> Not directly related, but in addition to `resources`, you can specify [`spot_policy`](../reference/dstack.yml/fleet.md#instance_types), [`instance_types`](../reference/dstack.yml/fleet.md#instance_types), [`max_price`](../reference/dstack.yml/fleet.md#max_price), [`region`](../reference/dstack.yml/fleet.md#max_price), and other [options](../reference/dstack.yml/fleet.md#).
+### Spot policy
-
+Backend fleets allow you to specify a `spot policy`. By default, it is set to `on-demand`. If you want to use spot instances, you must set it to `auto` if you plan to use both on-demand and spot instances, or to `spot` if only spot instances are allowed.
+
+
+
+```yaml
+type: fleet
+name: my-fleet
+
+nodes: 0..2
+
+# Uncomment to ensure instances are inter-connected
+#placement: cluster
+
+# Allows both on-demand and spot
+spot_policy: auto
+
+idle_duration: 1h
+
+resources:
+ gpu: 0..8
+```
+
+
+
+Note that run configurations must specify their own `spot policy` which is also set to `on-demand` by default.
### Backends
+Backend fleets allow you to set `backends` to specify which backends are allowed to be used.
+
### Idle duration
By default, instances of a backend fleet stay `idle` for 3 days and can be reused within that time.
@@ -432,7 +458,7 @@ ssh_config:
```
!!! info "Reference"
- The fleet configuration file supports many more options. See the [reference](../reference/dstack.yml/fleet.md).
+ The fleet configuration file supports additional options, including [`instance_types`](../reference/dstack.yml/fleet.md#instance_types), [`max_price`](../reference/dstack.yml/fleet.md#max_price), [`regions`](../reference/dstack.yml/fleet.md#max_price), among others. For the complete list, see the [reference](../reference/dstack.yml/fleet.md).
## Manage fleets
From 9c81898b5c7cac18e4f27608682146a5d31df425 Mon Sep 17 00:00:00 2001
From: Oleg
Date: Thu, 29 Jan 2026 12:30:46 +0300
Subject: [PATCH 041/640] Switch UI to pagination-based projects and users API
(#3503)
Switch UI to pagination-based projects and users API #3490
---
.../App/Login/LoginByGithubCallback/index.tsx | 2 +-
.../useCheckingForFleetsInProjectsOfMember.ts | 13 +-
frontend/src/hooks/useInfiniteScroll.ts | 61 +++++++--
frontend/src/hooks/useProjectFilter.ts | 10 +-
.../layouts/AppLayout/TutorialPanel/hooks.ts | 12 +-
.../src/pages/Events/List/hooks/useFilters.ts | 18 +--
frontend/src/pages/Models/List/hooks.tsx | 4 +-
frontend/src/pages/Project/List/index.tsx | 98 +++++++-------
.../Members/UsersAutosuggest/index.tsx | 4 +-
frontend/src/pages/Project/Members/index.tsx | 4 +-
.../hooks/useGetRunSpecFromYaml.ts | 1 +
.../pages/Runs/CreateDevEnvironment/index.tsx | 1 +
.../src/pages/Runs/List/hooks/useFilters.ts | 4 +-
.../src/pages/User/Details/Projects/index.tsx | 9 +-
frontend/src/pages/User/List/hooks.tsx | 39 ++++++
frontend/src/pages/User/List/index.tsx | 126 +++++++-----------
frontend/src/services/project.ts | 17 ++-
frontend/src/services/user.ts | 17 ++-
frontend/src/types/project.d.ts | 10 +-
frontend/src/types/user.d.ts | 12 ++
20 files changed, 270 insertions(+), 192 deletions(-)
create mode 100644 frontend/src/pages/User/List/hooks.tsx
diff --git a/frontend/src/App/Login/LoginByGithubCallback/index.tsx b/frontend/src/App/Login/LoginByGithubCallback/index.tsx
index af88aa72f1..45be311b6b 100644
--- a/frontend/src/App/Login/LoginByGithubCallback/index.tsx
+++ b/frontend/src/App/Login/LoginByGithubCallback/index.tsx
@@ -41,7 +41,7 @@ export const LoginByGithubCallback: React.FC = () => {
.then(async ({ creds: { token } }) => {
dispatch(setAuthData({ token }));
if (process.env.UI_VERSION === 'sky') {
- const result = await getProjects().unwrap();
+ const result = await getProjects({}).unwrap();
if (result?.length === 0) {
navigate(ROUTES.PROJECT.ADD);
return;
diff --git a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts
index d91b78a3b1..b330358b46 100644
--- a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts
+++ b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts
@@ -5,9 +5,12 @@ import { useGetOnlyNoFleetsProjectsQuery, useGetProjectsQuery } from 'services/p
type Args = { projectNames?: IProject['project_name'][] };
export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => {
- const { data: projectsData } = useGetProjectsQuery(undefined, {
- skip: !!projectNames?.length,
- });
+ const { data: projectsData } = useGetProjectsQuery(
+ {},
+ {
+ skip: !!projectNames?.length,
+ },
+ );
const { data: noFleetsProjectsData } = useGetOnlyNoFleetsProjectsQuery();
@@ -16,8 +19,8 @@ export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => {
return projectNames;
}
- if (projectsData) {
- return projectsData.map((project) => project.project_name);
+ if (projectsData?.data) {
+ return projectsData.data.map((project) => project.project_name);
}
return [];
diff --git a/frontend/src/hooks/useInfiniteScroll.ts b/frontend/src/hooks/useInfiniteScroll.ts
index 727586ab00..b11115d63f 100644
--- a/frontend/src/hooks/useInfiniteScroll.ts
+++ b/frontend/src/hooks/useInfiniteScroll.ts
@@ -9,10 +9,14 @@ const SCROLL_POSITION_GAP = 400;
type InfinityListArgs = Partial>;
type ListResponse = DataItem[];
+type ResponseWithDataProp = { data: ListResponse; total_count: number };
+
+type LazyQueryResponse = ResponseWithDataProp | ListResponse;
type UseInfinityParams = {
- useLazyQuery: UseLazyQuery, any>>;
+ useLazyQuery: UseLazyQuery, any>>;
args: { limit?: number } & Args;
+ getResponseItems?: (listItem: DataItem) => Partial;
getPaginationParams: (listItem: DataItem) => Partial;
skip?: boolean;
// options?: UseQueryStateOptions, Record>;
@@ -26,22 +30,31 @@ export const useInfiniteScroll = ({
skip,
}: UseInfinityParams) => {
const [data, setData] = useState>([]);
+ const [totalCount, setTotalCount] = useState();
const scrollElement = useRef(document.documentElement);
const isLoadingRef = useRef(false);
+ const isDisabledMoreRef = useRef(false);
const lastRequestParams = useRef(undefined);
- const [disabledMore, setDisabledMore] = useState(false);
const { limit, ...argsProp } = args;
const lastArgsProps = useRef>(null);
const [getItems, { isLoading, isFetching }] = useLazyQuery({ ...args } as Args);
const getDataRequest = (params: Args) => {
- lastRequestParams.current = params;
+ if (isEqual(params, lastRequestParams.current)) {
+ return Promise.reject();
+ }
- return getItems({
+ const request = getItems({
limit,
...params,
} as Args).unwrap();
+
+ request.then(() => {
+ lastRequestParams.current = { ...params };
+ });
+
+ return request;
};
const getEmptyList = () => {
@@ -49,9 +62,18 @@ export const useInfiniteScroll = ({
setData([]);
- getDataRequest(argsProp as Args).then((result) => {
- setDisabledMore(false);
- setData(result as ListResponse);
+ getDataRequest(argsProp as Args).then((result: LazyQueryResponse) => {
+ // setDisabledMore(false);
+ isDisabledMoreRef.current = false;
+
+ if ('data' in result) {
+ setData(result.data as ListResponse);
+ setTotalCount(result.total_count);
+ } else {
+ setData(result as ListResponse);
+ setTotalCount();
+ }
+
isLoadingRef.current = false;
});
};
@@ -64,7 +86,7 @@ export const useInfiniteScroll = ({
}, [argsProp, lastArgsProps, skip]);
const getMore = async () => {
- if (isLoadingRef.current || disabledMore || skip) {
+ if (isLoadingRef.current || isDisabledMoreRef.current || skip) {
return;
}
@@ -76,10 +98,20 @@ export const useInfiniteScroll = ({
...getPaginationParams(data[data.length - 1]),
} as Args);
- if (result.length > 0) {
- setData((prev) => [...prev, ...result]);
+ let listResponse: ListResponse;
+
+ if ('data' in result) {
+ listResponse = result.data;
+ setTotalCount(result.total_count);
+ } else {
+ listResponse = result;
+ setTotalCount();
+ }
+
+ if (listResponse.length > 0) {
+ setData((prev) => [...prev, ...listResponse]);
} else {
- setDisabledMore(true);
+ isDisabledMoreRef.current = true;
}
} catch (e) {
console.log(e);
@@ -87,7 +119,7 @@ export const useInfiniteScroll = ({
setTimeout(() => {
isLoadingRef.current = false;
- }, 10);
+ }, 50);
};
useLayoutEffect(() => {
@@ -101,7 +133,7 @@ export const useInfiniteScroll = ({
}, [data]);
const onScroll = useCallback(() => {
- if (disabledMore || isLoadingRef.current) {
+ if (isDisabledMoreRef.current || isLoadingRef.current) {
return;
}
@@ -112,7 +144,7 @@ export const useInfiniteScroll = ({
if (scrollPositionFromBottom < SCROLL_POSITION_GAP) {
getMore().catch(console.log);
}
- }, [disabledMore, getMore]);
+ }, [getMore]);
useEffect(() => {
document.addEventListener('scroll', onScroll);
@@ -126,6 +158,7 @@ export const useInfiniteScroll = ({
return {
data,
+ totalCount,
isLoading: isLoading || (data.length === 0 && isFetching),
isLoadingMore,
refreshList: getEmptyList,
diff --git a/frontend/src/hooks/useProjectFilter.ts b/frontend/src/hooks/useProjectFilter.ts
index b4573b1f8e..58e573d31d 100644
--- a/frontend/src/hooks/useProjectFilter.ts
+++ b/frontend/src/hooks/useProjectFilter.ts
@@ -16,20 +16,20 @@ export const useProjectFilter = ({ localStorePrefix }: Args) => {
null,
);
- const { data: projectsData } = useGetProjectsQuery();
+ const { data: projectsData } = useGetProjectsQuery({});
const projectOptions = useMemo(() => {
- if (!projectsData?.length) return [];
+ if (!projectsData?.data?.length) return [];
- return projectsData.map((project) => ({ label: project.project_name, value: project.project_name }));
+ return projectsData.data.map((project) => ({ label: project.project_name, value: project.project_name }));
}, [projectsData]);
useEffect(() => {
- if (!projectsData || !selectedProject) {
+ if (!projectsData?.data || !selectedProject) {
return;
}
- const hasSelectedProject = projectsData.some(({ project_name }) => selectedProject?.value === project_name);
+ const hasSelectedProject = projectsData.data.some(({ project_name }) => selectedProject?.value === project_name);
if (!hasSelectedProject) {
setSelectedProject(null);
diff --git a/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts b/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts
index 74d166ba8d..305a711a05 100644
--- a/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts
+++ b/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts
@@ -44,7 +44,7 @@ export const useTutorials = () => {
} = useAppSelector(selectTutorialPanel);
const { data: userBillingData } = useGetUserBillingInfoQuery({ username: useName ?? '' }, { skip: !useName });
- const { data: projectData } = useGetProjectsQuery();
+ const { data: projectData } = useGetProjectsQuery({});
const { data: runsData } = useGetRunsQuery({
limit: 1,
});
@@ -54,14 +54,14 @@ export const useTutorials = () => {
useEffect(() => {
if (
userBillingData &&
- projectData &&
+ projectData?.data &&
runsData &&
!completeIsChecked.current &&
location.pathname !== ROUTES.PROJECT.ADD
) {
const billingCompleted = userBillingData.balance > 0;
const configureCLICompleted = runsData.length > 0;
- const createProjectCompleted = projectData.length > 0;
+ const createProjectCompleted = projectData.data.length > 0;
let tempHideStartUp = hideStartUp;
@@ -88,7 +88,7 @@ export const useTutorials = () => {
}, [userBillingData, runsData, projectData, location.pathname]);
useEffect(() => {
- if (projectData && projectData.length > 0 && !createProjectCompleted) {
+ if (projectData?.data && projectData.data.length > 0 && !createProjectCompleted) {
dispatch(
updateTutorialPanelState({
createProjectCompleted: true,
@@ -114,8 +114,8 @@ export const useTutorials = () => {
}, []);
const startConfigCliTutorial = useCallback(() => {
- if (projectData?.length) {
- navigate(ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(projectData[0].project_name));
+ if (projectData?.data?.length) {
+ navigate(ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(projectData.data[0].project_name));
}
}, [projectData]);
diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts
index d9457f9064..535582195b 100644
--- a/frontend/src/pages/Events/List/hooks/useFilters.ts
+++ b/frontend/src/pages/Events/List/hooks/useFilters.ts
@@ -77,8 +77,8 @@ const targetTypes = [
export const useFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
- const { data: projectsData } = useGetProjectsQuery();
- const { data: usersData } = useGetUserListQuery();
+ const { data: projectsData } = useGetProjectsQuery({});
+ const { data: usersData } = useGetUserListQuery({});
const [propertyFilterQuery, setPropertyFilterQuery] = useState(() =>
requestParamsToTokens({ searchParams, filterKeys }),
@@ -92,7 +92,7 @@ export const useFilters = () => {
const filteringOptions = useMemo(() => {
const options: PropertyFilterProps.FilteringOption[] = [];
- projectsData?.forEach(({ project_name }) => {
+ projectsData?.data?.forEach(({ project_name }) => {
options.push({
propertyKey: filterKeys.TARGET_PROJECTS,
value: project_name,
@@ -104,7 +104,7 @@ export const useFilters = () => {
});
});
- usersData?.forEach(({ username }) => {
+ usersData?.data?.forEach(({ username }) => {
options.push({
propertyKey: filterKeys.TARGET_USERS,
value: username,
@@ -247,14 +247,16 @@ export const useFilters = () => {
...(params[filterKeys.TARGET_PROJECTS] && Array.isArray(params[filterKeys.TARGET_PROJECTS])
? {
[filterKeys.TARGET_PROJECTS]: params[filterKeys.TARGET_PROJECTS]?.map(
- (name: string) => projectsData?.find(({ project_name }) => project_name === name)?.['project_id'],
+ (name: string) =>
+ projectsData?.data?.find(({ project_name }) => project_name === name)?.['project_id'],
),
}
: {}),
...(params[filterKeys.WITHIN_PROJECTS] && Array.isArray(params[filterKeys.WITHIN_PROJECTS])
? {
[filterKeys.WITHIN_PROJECTS]: params[filterKeys.WITHIN_PROJECTS]?.map(
- (name: string) => projectsData?.find(({ project_name }) => project_name === name)?.['project_id'],
+ (name: string) =>
+ projectsData?.data?.find(({ project_name }) => project_name === name)?.['project_id'],
),
}
: {}),
@@ -262,7 +264,7 @@ export const useFilters = () => {
...(params[filterKeys.TARGET_USERS] && Array.isArray(params[filterKeys.TARGET_USERS])
? {
[filterKeys.TARGET_USERS]: params[filterKeys.TARGET_USERS]?.map(
- (name: string) => usersData?.find(({ username }) => username === name)?.['id'],
+ (name: string) => usersData?.data?.find(({ username }) => username === name)?.['id'],
),
}
: {}),
@@ -270,7 +272,7 @@ export const useFilters = () => {
...(params[filterKeys.ACTORS] && Array.isArray(params[filterKeys.ACTORS])
? {
[filterKeys.ACTORS]: params[filterKeys.ACTORS]?.map(
- (name: string) => usersData?.find(({ username }) => username === name)?.['id'],
+ (name: string) => usersData?.data?.find(({ username }) => username === name)?.['id'],
),
}
: {}),
diff --git a/frontend/src/pages/Models/List/hooks.tsx b/frontend/src/pages/Models/List/hooks.tsx
index 912875b394..461bf28a3b 100644
--- a/frontend/src/pages/Models/List/hooks.tsx
+++ b/frontend/src/pages/Models/List/hooks.tsx
@@ -129,7 +129,7 @@ const filterKeys: Record = {
export const useFilters = (localStorePrefix = 'models-list-page') => {
const [searchParams, setSearchParams] = useSearchParams();
const { projectOptions } = useProjectFilter({ localStorePrefix });
- const { data: usersData } = useGetUserListQuery();
+ const { data: usersData } = useGetUserListQuery({});
const [propertyFilterQuery, setPropertyFilterQuery] = useState(() =>
requestParamsToTokens({ searchParams, filterKeys }),
@@ -151,7 +151,7 @@ export const useFilters = (localStorePrefix = 'models-list-page') => {
});
});
- usersData?.forEach(({ username }) => {
+ usersData?.data?.forEach(({ username }) => {
options.push({
propertyKey: filterKeys.USER_NAME,
value: username,
diff --git a/frontend/src/pages/Project/List/index.tsx b/frontend/src/pages/Project/List/index.tsx
index af4afcf5dc..eb896df91d 100644
--- a/frontend/src/pages/Project/List/index.tsx
+++ b/frontend/src/pages/Project/List/index.tsx
@@ -1,44 +1,36 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
-import { get as _get } from 'lodash';
-
-import {
- Button,
- ButtonWithConfirmation,
- Header,
- ListEmptyMessage,
- Pagination,
- SpaceBetween,
- Table,
- TextFilter,
-} from 'components';
-
-import { useBreadcrumbs, useCollection } from 'hooks';
+
+import { Button, ButtonWithConfirmation, Header, ListEmptyMessage, Loader, SpaceBetween, Table, TextFilter } from 'components';
+
+import { DEFAULT_TABLE_PAGE_SIZE } from 'consts';
+import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks';
import { ROUTES } from 'routes';
-import { useGetProjectsQuery } from 'services/project';
+import { useLazyGetProjectsQuery } from 'services/project';
import { useCheckAvailableProjectPermission } from '../hooks/useCheckAvailableProjectPermission';
import { useDeleteProject } from '../hooks/useDeleteProject';
import { useColumnsDefinitions } from './hooks';
-const SEARCHABLE_COLUMNS = ['project_name', 'owner.username'];
-
export const ProjectList: React.FC = () => {
const { t } = useTranslation();
- const { isLoading, isFetching, data, refetch } = useGetProjectsQuery();
const { isAvailableDeletingPermission, isAvailableProjectManaging } = useCheckAvailableProjectPermission();
const { deleteProject, deleteProjects, isDeleting } = useDeleteProject();
+ const [filteringText, setFilteringText] = useState('');
+ const [namePattern, setNamePattern] = useState('');
const navigate = useNavigate();
- const sortedData = useMemo(() => {
- if (!data) return [];
+ const { data, isLoading, refreshList, isLoadingMore, totalCount } = useInfiniteScroll({
+ useLazyQuery: useLazyGetProjectsQuery,
+ args: { name_pattern: namePattern, limit: DEFAULT_TABLE_PAGE_SIZE },
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- return [...data].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
- }, [data]);
+ getPaginationParams: (lastProject) => ({
+ prev_created_at: lastProject.created_at,
+ prev_id: lastProject.project_id,
+ }),
+ });
useBreadcrumbs([
{
@@ -51,7 +43,24 @@ export const ProjectList: React.FC = () => {
navigate(ROUTES.PROJECT.ADD);
};
+ const onClearFilter = () => {
+ setNamePattern('');
+ setFilteringText('');
+ };
+
const renderEmptyMessage = (): React.ReactNode => {
+ if (isLoading) {
+ return null;
+ }
+
+ if (filteringText) {
+ return (
+
+ {t('projects.nomatch_message_button_label')}
+
+ );
+ }
+
return (
{isAvailableProjectManaging && {t('common.create')} }
@@ -59,28 +68,10 @@ export const ProjectList: React.FC = () => {
);
};
- const renderNoMatchMessage = (onClearFilter: () => void): React.ReactNode => {
- return (
-
- {t('projects.nomatch_message_button_label')}
-
- );
- };
-
- const { items, actions, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection(sortedData, {
+ const { items, collectionProps } = useCollection(data, {
filtering: {
empty: renderEmptyMessage(),
- noMatch: renderNoMatchMessage(() => actions.setFiltering('')),
-
- filteringFunction: (projectItem: IProject, filteringText) => {
- const filteringTextLowerCase = filteringText.toLowerCase();
-
- return SEARCHABLE_COLUMNS.map((key) => _get(projectItem, key)).some(
- (value) => typeof value === 'string' && value.trim().toLowerCase().indexOf(filteringTextLowerCase) > -1,
- );
- },
},
- pagination: { pageSize: 20 },
selection: {},
});
@@ -104,9 +95,9 @@ export const ProjectList: React.FC = () => {
});
const renderCounter = () => {
- if (!data?.length) return '';
+ if (typeof totalCount !== 'number') return '';
- return `(${data.length})`;
+ return `(${totalCount})`;
};
return (
@@ -116,7 +107,7 @@ export const ProjectList: React.FC = () => {
variant="full-page"
columnDefinitions={columns}
items={items}
- loading={isLoading || isFetching}
+ loading={isLoading}
loadingText={t('common.loading')}
selectionType={isAvailableProjectManaging ? 'multi' : undefined}
stickyHeader={true}
@@ -141,9 +132,9 @@ export const ProjectList: React.FC = () => {
)
@@ -154,13 +145,14 @@ export const ProjectList: React.FC = () => {
}
filter={
setFilteringText(detail.filteringText)}
+ onDelayedChange={() => setNamePattern(filteringText)}
disabled={isLoading}
+ filteringPlaceholder={t('projects.search_placeholder') || ''}
/>
}
- pagination={ }
+ footer={ }
/>
>
);
diff --git a/frontend/src/pages/Project/Members/UsersAutosuggest/index.tsx b/frontend/src/pages/Project/Members/UsersAutosuggest/index.tsx
index fdb7d9ff8c..7cd2dd7541 100644
--- a/frontend/src/pages/Project/Members/UsersAutosuggest/index.tsx
+++ b/frontend/src/pages/Project/Members/UsersAutosuggest/index.tsx
@@ -14,12 +14,12 @@ type AutosuggestOption = { value: string; label?: string };
export const UserAutosuggest: React.FC = ({ optionsFilter, onSelect: onSelectProp, ...props }) => {
const { t } = useTranslation();
const [value, setValue] = useState('');
- const { data: usersData, isLoading: isUsersLoading } = useGetUserListQuery();
+ const { data: usersData, isLoading: isUsersLoading } = useGetUserListQuery({});
const options: AutosuggestOption[] = useMemo(() => {
if (!usersData) return [];
- return usersData.map((user) => ({
+ return usersData?.data.map((user) => ({
value: user.username,
}));
}, [usersData]);
diff --git a/frontend/src/pages/Project/Members/index.tsx b/frontend/src/pages/Project/Members/index.tsx
index 007c64b9d7..d68916c6d8 100644
--- a/frontend/src/pages/Project/Members/index.tsx
+++ b/frontend/src/pages/Project/Members/index.tsx
@@ -32,7 +32,7 @@ import styles from './styles.module.scss';
export const ProjectMembers: React.FC = ({ members, loading, onChange, readonly, isAdmin, project }) => {
const { t } = useTranslation();
const [selectedItems, setSelectedItems] = useState([]);
- const { data: usersData } = useGetUserListQuery();
+ const { data: usersData } = useGetUserListQuery({});
const userData = useAppSelector(selectUserData);
const { handleJoinProject, handleLeaveProject, isMemberActionLoading } = useProjectMemberActions();
const [pushNotification] = useNotifications();
@@ -96,7 +96,7 @@ export const ProjectMembers: React.FC = ({ members, loading, onChange, r
return;
}
- const selectedUser = usersData?.find((u) => u.username === username);
+ const selectedUser = usersData?.data?.find((u) => u.username === username);
if (selectedUser) {
append({
diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGetRunSpecFromYaml.ts b/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGetRunSpecFromYaml.ts
index 70ec4d5342..7815d1daca 100644
--- a/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGetRunSpecFromYaml.ts
+++ b/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGetRunSpecFromYaml.ts
@@ -56,6 +56,7 @@ export const useGetRunSpecFromYaml = ({ projectName = '' }) => {
try {
await getRepo({ project_name: projectName, repo_id: repoId, include_creds: true }).unwrap();
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
await initRepo({
project_name: projectName,
diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx
index af52513760..fcac5194af 100644
--- a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx
+++ b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx
@@ -53,6 +53,7 @@ const envValidationSchema = yup.object({
is: true,
then: yup
.string()
+ // eslint-disable-next-line no-useless-escape
.matches(/^(https?):\/\/([^\s\/?#]+)((?:\/[^\s?#]*)*)(?::\/(.*))?$/i, urlFormatError)
.required(requiredFieldError),
}),
diff --git a/frontend/src/pages/Runs/List/hooks/useFilters.ts b/frontend/src/pages/Runs/List/hooks/useFilters.ts
index d675f78ea5..82f1ca40bd 100644
--- a/frontend/src/pages/Runs/List/hooks/useFilters.ts
+++ b/frontend/src/pages/Runs/List/hooks/useFilters.ts
@@ -23,7 +23,7 @@ export const useFilters = ({ localStorePrefix }: Args) => {
const [searchParams, setSearchParams] = useSearchParams();
const [onlyActive, setOnlyActive] = useState(() => searchParams.get('only_active') === 'true');
const { projectOptions } = useProjectFilter({ localStorePrefix });
- const { data: usersData } = useGetUserListQuery();
+ const { data: usersData } = useGetUserListQuery({});
const [propertyFilterQuery, setPropertyFilterQuery] = useState(() =>
requestParamsToTokens({ searchParams, filterKeys }),
@@ -46,7 +46,7 @@ export const useFilters = ({ localStorePrefix }: Args) => {
});
});
- usersData?.forEach(({ username }) => {
+ usersData?.data?.forEach(({ username }) => {
options.push({
propertyKey: filterKeys.USER_NAME,
value: username,
diff --git a/frontend/src/pages/User/Details/Projects/index.tsx b/frontend/src/pages/User/Details/Projects/index.tsx
index d1370aec93..2cb9885ab3 100644
--- a/frontend/src/pages/User/Details/Projects/index.tsx
+++ b/frontend/src/pages/User/Details/Projects/index.tsx
@@ -18,7 +18,7 @@ export const UserProjectList: React.FC = () => {
const params = useParams();
const paramUserName = params.userName ?? '';
- const { isLoading, isFetching, data } = useGetProjectsQuery();
+ const { isLoading, isFetching, data } = useGetProjectsQuery({ limit: 200 });
useBreadcrumbs([
{
@@ -41,11 +41,10 @@ export const UserProjectList: React.FC = () => {
};
const filteredData = useMemo(() => {
- if (!data) return [];
+ if (!data?.data) return [];
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- return [...data]
+ // eslint-disable-next-line no-unsafe-optional-chaining
+ return [...data?.data]
.filter((p) => p.owner.username === paramUserName)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}, [data]);
diff --git a/frontend/src/pages/User/List/hooks.tsx b/frontend/src/pages/User/List/hooks.tsx
new file mode 100644
index 0000000000..7f9f24549c
--- /dev/null
+++ b/frontend/src/pages/User/List/hooks.tsx
@@ -0,0 +1,39 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+
+import { Link, NavigateLink } from 'components';
+
+import { DATE_TIME_FORMAT } from 'consts';
+import { ROUTES } from 'routes';
+
+export const useColumnDefinitions = () => {
+ const { t } = useTranslation();
+
+ return useMemo(() => {
+ return [
+ {
+ id: 'name',
+ header: t('users.user_name'),
+ cell: (item: IUser) => (
+ {item.username}
+ ),
+ },
+ {
+ id: 'email',
+ header: t('users.email'),
+ cell: (item: IUser) => (item.email ? {item.email} : '-'),
+ },
+ {
+ id: 'global_role',
+ header: t('users.global_role'),
+ cell: (item: IUser) => t(`roles.${item.global_role}`),
+ },
+ process.env.UI_VERSION === 'sky' && {
+ id: 'created_at',
+ header: t('users.created_at'),
+ cell: (item: IUser) => format(new Date(item.created_at), DATE_TIME_FORMAT),
+ },
+ ].filter(Boolean);
+ }, []);
+};
diff --git a/frontend/src/pages/User/List/index.tsx b/frontend/src/pages/User/List/index.tsx
index 61d14e83fc..66d5e2e46f 100644
--- a/frontend/src/pages/User/List/index.tsx
+++ b/frontend/src/pages/User/List/index.tsx
@@ -1,47 +1,38 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
-import { format } from 'date-fns';
-
-import {
- Box,
- Button,
- ConfirmationDialog,
- Header,
- Link,
- ListEmptyMessage,
- NavigateLink,
- Pagination,
- SpaceBetween,
- Table,
- TextFilter,
-} from 'components';
-
-import { DATE_TIME_FORMAT } from 'consts';
-import { useAppSelector, useBreadcrumbs, useCollection, useNotifications } from 'hooks';
-import { getServerError, includeSubString } from 'libs';
+
+import { Box, Button, ConfirmationDialog, Header, ListEmptyMessage, Loader, SpaceBetween, Table, TextFilter } from 'components';
+
+import { DEFAULT_TABLE_PAGE_SIZE } from 'consts';
+import { useAppSelector, useBreadcrumbs, useCollection, useInfiniteScroll, useNotifications } from 'hooks';
+import { getServerError } from 'libs';
import { ROUTES } from 'routes';
-import { useDeleteUsersMutation, useGetUserListQuery } from 'services/user';
+import { useDeleteUsersMutation, useLazyGetUserListQuery } from 'services/user';
import { selectUserData } from 'App/slice';
+import { useColumnDefinitions } from './hooks';
+
export const UserList: React.FC = () => {
const { t } = useTranslation();
const [showDeleteConfirm, setShowConfirmDelete] = useState(false);
+ const [filteringText, setFilteringText] = useState('');
+ const [namePattern, setNamePattern] = useState('');
const userData = useAppSelector(selectUserData);
const userGlobalRole = userData?.global_role ?? '';
- const { isLoading, isFetching, data, refetch } = useGetUserListQuery();
const [deleteUsers, { isLoading: isDeleting }] = useDeleteUsersMutation();
const navigate = useNavigate();
const [pushNotification] = useNotifications();
- const sortedData = useMemo(() => {
- if (!data) return [];
+ const { data, isLoading, refreshList, isLoadingMore, totalCount } = useInfiniteScroll({
+ useLazyQuery: useLazyGetUserListQuery,
+ args: { name_pattern: namePattern, limit: DEFAULT_TABLE_PAGE_SIZE },
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- return [...data].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
- }, [data]);
+ getPaginationParams: (lastUser) => ({
+ prev_created_at: lastUser.created_at,
+ }),
+ });
useBreadcrumbs([
{
@@ -50,30 +41,7 @@ export const UserList: React.FC = () => {
},
]);
- const COLUMN_DEFINITIONS = [
- {
- id: 'name',
- header: t('users.user_name'),
- cell: (item: IUser) => (
- {item.username}
- ),
- },
- {
- id: 'email',
- header: t('users.email'),
- cell: (item: IUser) => (item.email ? {item.email} : '-'),
- },
- {
- id: 'global_role',
- header: t('users.global_role'),
- cell: (item: IUser) => t(`roles.${item.global_role}`),
- },
- process.env.UI_VERSION === 'sky' && {
- id: 'created_at',
- header: t('users.created_at'),
- cell: (item: IUser) => format(new Date(item.created_at), DATE_TIME_FORMAT),
- },
- ].filter(Boolean);
+ const columns = useColumnDefinitions();
const toggleDeleteConfirm = () => {
setShowConfirmDelete((val) => !val);
@@ -83,7 +51,24 @@ export const UserList: React.FC = () => {
navigate(ROUTES.USER.ADD);
};
+ const onClearFilter = () => {
+ setNamePattern('');
+ setFilteringText('');
+ };
+
const renderEmptyMessage = (): React.ReactNode => {
+ if (isLoading) {
+ return null;
+ }
+
+ if (filteringText) {
+ return (
+
+ {t('common.clearFilter')}
+
+ );
+ }
+
return (
{t('common.add')}
@@ -91,22 +76,10 @@ export const UserList: React.FC = () => {
);
};
- const renderNoMatchMessage = (onClearFilter: () => void): React.ReactNode => {
- return (
-
- {t('common.clearFilter')}
-
- );
- };
-
- const { items, actions, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection(sortedData, {
+ const { items, actions, collectionProps } = useCollection(data, {
filtering: {
empty: renderEmptyMessage(),
- noMatch: renderNoMatchMessage(() => actions.setFiltering('')),
- filteringFunction: (user, filteringText) =>
- includeSubString(user.username, filteringText) || includeSubString(user.email ?? '', filteringText),
},
- pagination: { pageSize: 20 },
selection: {},
});
@@ -145,13 +118,9 @@ export const UserList: React.FC = () => {
}, [collectionProps.selectedItems]);
const renderCounter = () => {
- const { selectedItems } = collectionProps;
-
- if (!data?.length) return '';
-
- if (selectedItems?.length) return `(${selectedItems?.length}/${data?.length ?? 0})`;
+ if (typeof totalCount !== 'number') return '';
- return `(${data.length})`;
+ return `(${totalCount})`;
};
return (
@@ -160,9 +129,11 @@ export const UserList: React.FC = () => {
{...collectionProps}
variant="full-page"
isItemDisabled={getIsTableItemDisabled}
- columnDefinitions={COLUMN_DEFINITIONS}
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ columnDefinitions={columns}
items={items}
- loading={isLoading || isFetching}
+ loading={isLoading}
loadingText={t('common.loading')}
selectionType="multi"
stickyHeader={true}
@@ -186,9 +157,9 @@ export const UserList: React.FC = () => {
}
@@ -198,13 +169,14 @@ export const UserList: React.FC = () => {
}
filter={
setFilteringText(detail.filteringText)}
+ onDelayedChange={() => setNamePattern(filteringText)}
filteringPlaceholder={t('users.search_placeholder')}
- countText={t('common.match_count_with_value', { count: filteredItemsCount })}
disabled={isLoading}
/>
}
- pagination={ }
+ footer={ }
/>
({
- getProjects: builder.query({
- query: () => {
+ getProjects: builder.query({
+ query: (body) => {
return {
url: API.PROJECTS.LIST(),
method: 'POST',
+ body: {
+ ...body,
+ return_total_count: true,
+ },
};
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- transformResponse: (response: any[]): IProject[] => response.map(transformProjectResponse),
+ transformResponse: (response: any): TGetProjectListResponse => ({
+ ...response,
+ data: response.projects.map(transformProjectResponse),
+ }),
providesTags: (result) =>
result
- ? [...result.map(({ project_name }) => ({ type: 'Projects' as const, id: project_name })), 'Projects']
+ ? [...result.data.map(({ project_name }) => ({ type: 'Projects' as const, id: project_name })), 'Projects']
: ['Projects'],
}),
@@ -201,8 +208,8 @@ export const projectApi = createApi({
export const {
useGetProjectsQuery,
- useGetOnlyNoFleetsProjectsQuery,
useLazyGetProjectsQuery,
+ useGetOnlyNoFleetsProjectsQuery,
useGetProjectQuery,
useCreateProjectMutation,
useCreateWizardProjectMutation,
diff --git a/frontend/src/services/user.ts b/frontend/src/services/user.ts
index 05b589adc5..68aa5cb84b 100644
--- a/frontend/src/services/user.ts
+++ b/frontend/src/services/user.ts
@@ -37,16 +37,26 @@ export const userApi = createApi({
},
}),
- getUserList: builder.query({
- query: () => {
+ getUserList: builder.query({
+ query: (body) => {
return {
url: API.USERS.LIST(),
method: 'POST',
+ body: {
+ ...body,
+ return_total_count: true,
+ },
};
},
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ transformResponse: (response: any): TGetUserListResponse => ({
+ ...response,
+ data: response.users,
+ }),
+
providesTags: (result) =>
- result ? [...result.map(({ username }) => ({ type: 'User' as const, id: username })), 'User'] : ['User'],
+ result ? [...result.data.map(({ username }) => ({ type: 'User' as const, id: username })), 'User'] : ['User'],
}),
getUser: builder.query({
@@ -194,6 +204,7 @@ export const userApi = createApi({
export const {
useGetUserDataQuery,
useGetUserListQuery,
+ useLazyGetUserListQuery,
useGetUserQuery,
useCheckAuthTokenMutation,
useCreateUserMutation,
diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts
index babb4dab7a..8853a1a839 100644
--- a/frontend/src/types/project.d.ts
+++ b/frontend/src/types/project.d.ts
@@ -7,9 +7,15 @@ declare type TCreateWizardProjectParams = {
};
};
-declare type TGetProjectsParams = {
- only_no_fleets?: boolean;
+declare type TGetProjectListParams = TBaseRequestListParams & {
include_not_joined?: boolean;
+ return_total_count?: boolean;
+ name_pattern?: string;
+};
+
+declare type TGetProjectListResponse = {
+ total_count: number;
+ data: IProject[];
};
declare type TProjectBackend = {
diff --git a/frontend/src/types/user.d.ts b/frontend/src/types/user.d.ts
index 6e4b443bc1..cafda2920a 100644
--- a/frontend/src/types/user.d.ts
+++ b/frontend/src/types/user.d.ts
@@ -2,12 +2,24 @@ declare type TUserRole = 'user' | 'admin';
declare type TUserPermission = 'CAN_CREATE_PROJECTS';
declare type TUserPermissionKeys = 'can_create_projects';
+declare type TGetUserListParams = TBaseRequestListParams & {
+ return_total_count?: boolean;
+ name_pattern?: string;
+};
+
+declare type TGetUserListResponse = {
+ total_count: number;
+ data: IUser[];
+};
+
declare interface IUserResponseData {
id: string;
username: string;
global_role: TUserRole;
email: string | null;
permissions: Record;
+ created_at: string;
+ active: boolean;
}
declare interface IUser {
From c8d6d1c2b0683842b8623a5843da90fe5ec3c28c Mon Sep 17 00:00:00 2001
From: Oleg
Date: Fri, 30 Jan 2026 09:36:50 +0300
Subject: [PATCH 042/640] [UI] Add Spot policy configuration option to the
fleet wizard (#3519)
* [UI] Add Spot policy configuration option to the fleet wizard #3513
---
frontend/src/locale/en.json | 6 ++--
.../Fleets/Add/FleetFormFields/constants.tsx | 28 +++++++++++++++++++
.../Fleets/Add/FleetFormFields/index.tsx | 25 +++++++++++++++--
.../pages/Fleets/Add/FleetFormFields/type.ts | 1 +
frontend/src/pages/Fleets/Add/index.tsx | 22 +++++++++++----
frontend/src/pages/Project/Add/index.tsx | 16 ++++++++---
.../src/pages/Project/CreateWizard/index.tsx | 6 ++--
7 files changed, 89 insertions(+), 15 deletions(-)
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 342ce0cdd7..e11541edba 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -625,12 +625,14 @@
"name_placeholder": "Optional",
"name_constraint": "Example: 'my-fleet' or 'default'. If not specified, generated automatically.",
"min_instances": "Min number of instances",
- "min_instances_description": "Set it `0` to provision instances only when required",
+ "min_instances_description": "Set it '0' to provision instances only when required",
"max_instances": "Max number of instances",
"max_instances_description": "Required only if you want to set an upper limit",
"max_instances_placeholder": "Optional",
"idle_duration": "Idle duration",
- "idle_duration_description": "Example: '0s', '1m', '1h'"
+ "idle_duration_description": "Example: '0s', '1m', '1h'",
+ "spot_policy": "Spot policy",
+ "spot_policy_description": "Set it to 'auto' to allow the use of both on-demand and spot instances"
}
},
"volume": {
diff --git a/frontend/src/pages/Fleets/Add/FleetFormFields/constants.tsx b/frontend/src/pages/Fleets/Add/FleetFormFields/constants.tsx
index 372c4dcfab..7904a17746 100644
--- a/frontend/src/pages/Fleets/Add/FleetFormFields/constants.tsx
+++ b/frontend/src/pages/Fleets/Add/FleetFormFields/constants.tsx
@@ -2,6 +2,14 @@ import React from 'react';
import { get } from 'lodash';
import * as yup from 'yup';
+import { FleetFormFields } from './type';
+
+export const fleetFormDefaultValues: FleetFormFields = {
+ min_instances: 0,
+ idle_duration: '5m',
+ spot_policy: 'auto',
+};
+
export const FLEET_MIN_INSTANCES_INFO = {
header: Min number of instances ,
body: (
@@ -78,6 +86,26 @@ export const FLEET_IDLE_DURATION_INFO = {
),
};
+export const FLEET_SPOT_POLICY_INFO = {
+ header: Spot policy ,
+ body: (
+ <>
+
+ Some backends may support spot instances, also known as preemptive instances. Such instances come at a
+ significantly lower price but can be interrupted by the cloud provider at any time.
+
+
+ If you set spot_policy to auto, the fleet will allow the use of both types of
+ instances: on-demand and spot.
+
+
+ Note that run configurations must specify their own spot_policy, which by default is always{' '}
+ on-demand.
+
+ >
+ ),
+};
+
const requiredFieldError = 'This is required field';
const numberFieldError = 'This is number field';
diff --git a/frontend/src/pages/Fleets/Add/FleetFormFields/index.tsx b/frontend/src/pages/Fleets/Add/FleetFormFields/index.tsx
index a19e701366..4aa04c3935 100644
--- a/frontend/src/pages/Fleets/Add/FleetFormFields/index.tsx
+++ b/frontend/src/pages/Fleets/Add/FleetFormFields/index.tsx
@@ -1,11 +1,16 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { FormInput, InfoLink, SpaceBetween } from 'components';
+import { FormInput, FormSelect, InfoLink, SpaceBetween } from 'components';
import { useHelpPanel } from 'hooks';
-import { FLEET_IDLE_DURATION_INFO, FLEET_MAX_INSTANCES_INFO, FLEET_MIN_INSTANCES_INFO } from './constants';
+import {
+ FLEET_IDLE_DURATION_INFO,
+ FLEET_MAX_INSTANCES_INFO,
+ FLEET_MIN_INSTANCES_INFO,
+ FLEET_SPOT_POLICY_INFO,
+} from './constants';
import { FleetFormFieldsProps } from './type';
import type { FieldValues } from 'react-hook-form/dist/types/fields';
@@ -64,6 +69,22 @@ export function FleetFormFields({
type="number"
/>
+ openHelpPanel(FLEET_SPOT_POLICY_INFO)} />}
+ label={t('fleets.edit.spot_policy')}
+ constraintText={t('fleets.edit.spot_policy_description')}
+ control={control}
+ //eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ name={getFieldNameWitPrefix(`spot_policy`)}
+ disabled={disabledAllFields}
+ options={[
+ { label: 'auto', value: 'auto' },
+ { label: 'on-demand', value: 'on-demand' },
+ { label: 'spot', value: 'spot' },
+ ]}
+ />
+
openHelpPanel(FLEET_IDLE_DURATION_INFO)} />}
label={t('fleets.edit.idle_duration')}
diff --git a/frontend/src/pages/Fleets/Add/FleetFormFields/type.ts b/frontend/src/pages/Fleets/Add/FleetFormFields/type.ts
index 7351a60a6a..835ca83732 100644
--- a/frontend/src/pages/Fleets/Add/FleetFormFields/type.ts
+++ b/frontend/src/pages/Fleets/Add/FleetFormFields/type.ts
@@ -12,4 +12,5 @@ export type FleetFormFields = {
min_instances: number;
max_instances?: number;
idle_duration?: string;
+ spot_policy: TSpotPolicy;
};
diff --git a/frontend/src/pages/Fleets/Add/index.tsx b/frontend/src/pages/Fleets/Add/index.tsx
index b661d96cb0..d7390fc933 100644
--- a/frontend/src/pages/Fleets/Add/index.tsx
+++ b/frontend/src/pages/Fleets/Add/index.tsx
@@ -15,7 +15,12 @@ import { useApplyFleetMutation } from 'services/fleet';
import { DEFAULT_FLEET_INFO } from 'pages/Project/constants';
import { useYupValidationResolver } from 'pages/Project/hooks/useYupValidationResolver';
-import { getMaxInstancesValidator, getMinInstancesValidator, idleDurationValidator } from './FleetFormFields/constants';
+import {
+ fleetFormDefaultValues,
+ getMaxInstancesValidator,
+ getMinInstancesValidator,
+ idleDurationValidator,
+} from './FleetFormFields/constants';
import { FleetFormFields } from './FleetFormFields';
import { IFleetWizardForm } from './types';
@@ -33,6 +38,7 @@ const fleetValidationSchema = yup.object({
min_instances: getMinInstancesValidator('max_instances'),
max_instances: getMaxInstancesValidator('min_instances'),
idle_duration: idleDurationValidator,
+ spot_policy: yup.string().required(requiredFieldError),
});
export const FleetAdd: React.FC = () => {
@@ -52,9 +58,8 @@ export const FleetAdd: React.FC = () => {
const formMethods = useForm({
resolver,
defaultValues: {
+ ...fleetFormDefaultValues,
project_name: paramProjectName,
- min_instances: 0,
- idle_duration: '5m',
},
});
@@ -62,7 +67,7 @@ export const FleetAdd: React.FC = () => {
const formValues = watch();
const getFormValuesForFleetApplying = (): IApplyFleetPlanRequestRequest => {
- const { min_instances, max_instances, idle_duration, name } = getValues();
+ const { min_instances, max_instances, idle_duration, name, spot_policy } = getValues();
return {
plan: {
@@ -74,6 +79,7 @@ export const FleetAdd: React.FC = () => {
...(max_instances ? { max: max_instances } : {}),
},
...(idle_duration ? { idle_duration } : {}),
+ spot_policy,
},
profile: {},
},
@@ -185,7 +191,13 @@ export const FleetAdd: React.FC = () => {
};
const getDefaultFleetSummary = () => {
- const summaryFields: Array = ['name', 'min_instances', 'max_instances', 'idle_duration'];
+ const summaryFields: Array = [
+ 'name',
+ 'min_instances',
+ 'max_instances',
+ 'idle_duration',
+ 'spot_policy',
+ ];
const result: string[] = [];
diff --git a/frontend/src/pages/Project/Add/index.tsx b/frontend/src/pages/Project/Add/index.tsx
index 23a9eb0a19..23cbc4c006 100644
--- a/frontend/src/pages/Project/Add/index.tsx
+++ b/frontend/src/pages/Project/Add/index.tsx
@@ -16,6 +16,7 @@ import { useCreateProjectMutation } from 'services/project';
import { FleetFormFields } from 'pages/Fleets/Add/FleetFormFields';
import {
+ fleetFormDefaultValues,
getMaxInstancesValidator,
getMinInstancesValidator,
idleDurationValidator,
@@ -51,6 +52,7 @@ const projectValidationSchema = yup.object({
is: true,
then: idleDurationValidator,
}),
+ spot_policy: yup.string().required(requiredFieldError),
}),
});
@@ -72,9 +74,8 @@ export const ProjectAdd: React.FC = () => {
defaultValues: {
is_public: false,
fleet: {
+ ...fleetFormDefaultValues,
enable_default: true,
- min_instances: 0,
- idle_duration: '5m',
},
},
});
@@ -93,7 +94,7 @@ export const ProjectAdd: React.FC = () => {
const getFormValuesForFleetApplying = (): IApplyFleetPlanRequestRequest => {
const {
- fleet: { min_instances, max_instances, idle_duration, name },
+ fleet: { min_instances, max_instances, idle_duration, name, spot_policy },
} = getValues();
return {
@@ -106,6 +107,7 @@ export const ProjectAdd: React.FC = () => {
...(max_instances ? { max: max_instances } : {}),
},
...(idle_duration ? { idle_duration } : {}),
+ spot_policy,
},
profile: {},
},
@@ -231,7 +233,13 @@ export const ProjectAdd: React.FC = () => {
};
const getDefaultFleetSummary = () => {
- const summaryFields: Array = ['name', 'min_instances', 'max_instances', 'idle_duration'];
+ const summaryFields: Array = [
+ 'name',
+ 'min_instances',
+ 'max_instances',
+ 'idle_duration',
+ 'spot_policy',
+ ];
const result: string[] = [];
diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx
index 83cf3804d3..ecf2d413e5 100644
--- a/frontend/src/pages/Project/CreateWizard/index.tsx
+++ b/frontend/src/pages/Project/CreateWizard/index.tsx
@@ -30,6 +30,7 @@ import { useCreateWizardProjectMutation } from 'services/project';
import { FleetFormFields } from '../../Fleets/Add/FleetFormFields';
import {
+ fleetFormDefaultValues,
getMaxInstancesValidator,
getMinInstancesValidator,
idleDurationValidator,
@@ -69,6 +70,7 @@ const projectValidationSchema = yup.object({
is: true,
then: idleDurationValidator,
}),
+ spot_policy: yup.string().required(requiredFieldError),
}),
});
@@ -127,9 +129,8 @@ export const CreateProjectWizard: React.FC = () => {
defaultValues: {
project_type: 'gpu_marketplace',
fleet: {
+ ...fleetFormDefaultValues,
enable_default: true,
- min_instances: 0,
- idle_duration: '5m',
},
},
});
@@ -319,6 +320,7 @@ export const CreateProjectWizard: React.FC = () => {
'min_instances',
'max_instances',
'idle_duration',
+ 'spot_policy',
];
const result: string[] = [];
From dbdbf4d5cfe1b293d7e43d5955b7420d1ab9d19c Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Fri, 30 Jan 2026 11:11:47 +0000
Subject: [PATCH 043/640] Rename event target filters in UI (#3517)
---
frontend/src/pages/Events/List/hooks/useFilters.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts
index 535582195b..6a82b3a654 100644
--- a/frontend/src/pages/Events/List/hooks/useFilters.ts
+++ b/frontend/src/pages/Events/List/hooks/useFilters.ts
@@ -168,17 +168,17 @@ export const useFilters = () => {
{
key: filterKeys.TARGET_VOLUMES,
operators: ['='],
- propertyLabel: 'Target volumes',
+ propertyLabel: 'Target volume IDs',
},
{
key: filterKeys.TARGET_GATEWAYS,
operators: ['='],
- propertyLabel: 'Target gateways',
+ propertyLabel: 'Target gateway IDs',
},
{
key: filterKeys.TARGET_SECRETS,
operators: ['='],
- propertyLabel: 'Target secrets',
+ propertyLabel: 'Target secret IDs',
},
{
From 1dc61213578c80204db94d60f36ebdd9ae90ca77 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Sat, 31 Jan 2026 22:30:53 +0100
Subject: [PATCH 044/640] [Docs] Add dstack skill (#3525)
---
.github/workflows/build-docs.yml | 1 +
.gitignore | 1 +
contributing/DOCS.md | 103 ++++
docs/.nojekyll | 0
docs/blog/posts/0_20.md | 2 +-
.../posts/digitalocean-and-amd-dev-cloud.md | 2 +-
docs/blog/posts/dstack-sky.md | 2 +-
docs/blog/posts/hotaisle.md | 2 +-
docs/blog/posts/nebius.md | 2 +-
docs/blog/posts/nvidia-and-amd-on-vultr.md | 2 +-
docs/docs/concepts/backends.md | 5 +
docs/docs/concepts/dev-environments.md | 7 +-
docs/docs/concepts/events.md | 5 +
docs/docs/concepts/fleets.md | 5 +
docs/docs/concepts/gateways.md | 5 +
docs/docs/concepts/metrics.md | 5 +
docs/docs/concepts/projects.md | 5 +
docs/docs/concepts/secrets.md | 5 +
docs/docs/concepts/services.md | 7 +-
docs/docs/concepts/tasks.md | 7 +-
docs/docs/concepts/volumes.md | 5 +
docs/docs/guides/protips.md | 5 +
docs/docs/guides/server-deployment.md | 5 +
docs/docs/guides/troubleshooting.md | 5 +
docs/docs/guides/upgrade.md | 5 +
docs/docs/index.md | 9 +-
.../index.md => installation.md} | 21 +-
docs/docs/quickstart.md | 7 +-
docs/docs/reference/api/rest/openapi.json | 1 +
docs/examples.md | 2 +
examples/accelerators/amd/README.md | 5 +
examples/accelerators/intel/README.md | 7 +-
examples/accelerators/tenstorrent/README.md | 7 +-
examples/accelerators/tpu/README.md | 5 +
examples/clusters/aws/README.md | 7 +-
examples/clusters/crusoe/README.md | 3 +-
examples/clusters/gcp/README.md | 5 +
examples/clusters/lambda/README.md | 3 +-
examples/clusters/nccl-rccl-tests/README.md | 5 +
.../distributed-training/axolotl/README.md | 5 +
.../distributed-training/ray-ragen/README.md | 7 +-
examples/distributed-training/trl/README.md | 5 +
examples/inference/nim/README.md | 2 +-
examples/inference/sglang/README.md | 5 +
examples/inference/tgi/README.md | 2 +-
examples/inference/trtllm/README.md | 2 +-
examples/inference/vllm/README.md | 3 +-
examples/models/wan22/README.md | 5 +
.../single-node-training/axolotl/README.md | 5 +
examples/single-node-training/trl/README.md | 5 +
mkdocs.yml | 50 +-
pyproject.toml | 1 -
scripts/docs/gen_llms_files.py | 243 ++++++++
scripts/docs/gen_schema_reference.py | 15 +-
scripts/docs/hooks.py | 225 ++++++++
skills/dstack/SKILL.md | 546 ++++++++++++++++++
56 files changed, 1330 insertions(+), 81 deletions(-)
create mode 100644 docs/.nojekyll
rename docs/docs/{installation/index.md => installation.md} (87%)
create mode 100644 docs/docs/reference/api/rest/openapi.json
create mode 100644 scripts/docs/gen_llms_files.py
create mode 100644 scripts/docs/hooks.py
create mode 100644 skills/dstack/SKILL.md
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index 140ba82204..6ca99d764a 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -23,3 +23,4 @@ jobs:
name: site
path: site
retention-days: 1
+ include-hidden-files: true
diff --git a/.gitignore b/.gitignore
index 0d2163398d..bed3aa2ccc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ uv.lock
/src/dstack/_internal/server/statics
profiling_results.html
+docs/docs/reference/api/rest/openapi.json
diff --git a/contributing/DOCS.md b/contributing/DOCS.md
index c20076466e..a885c5f51e 100644
--- a/contributing/DOCS.md
+++ b/contributing/DOCS.md
@@ -47,3 +47,106 @@ If you want to build static files, you can use the following command:
```shell
uv run mkdocs build -s
```
+
+## Documentation build system
+
+The documentation uses a custom build system with MkDocs hooks to generate various files dynamically.
+
+### Build hooks
+
+The build process is customized via hooks in `scripts/docs/hooks.py`:
+
+#### 1. Example materialization
+
+Example pages like `examples/single-node-training/trl/index.md` are stubs that reference `README.md` files in the repository root:
+- **Stub location**: `docs/examples/single-node-training/trl/index.md`
+- **Content source**: `examples/single-node-training/trl/README.md`
+
+During the build, the hook reads the README content and uses it for rendering the HTML page.
+
+#### 2. Schema reference expansion
+
+Files in `docs/reference/**/*.md` can use `#SCHEMA#` placeholders that are expanded with generated schema documentation during the build.
+
+#### 3. llms.txt generation
+
+Two files are generated for LLM consumption:
+
+- **llms.txt**: Structured overview of documentation with titles and descriptions
+ - Generated from mkdocs nav structure
+ - Includes sections: Getting started, Concepts, Guides, Examples
+ - Excludes: Reference section
+ - Configuration: `scripts/docs/gen_llms_files.py` (INCLUDE_SECTIONS, EXCLUDE_SECTIONS)
+
+- **llms-full.txt**: Full concatenation of all pages from llms.txt
+ - Contains complete markdown content of all included pages
+
+The generation logic is in `scripts/docs/gen_llms_files.py` and uses:
+- `site_name`, `site_description`, `site_url` from `mkdocs.yml`
+- Page titles from mkdocs nav structure
+- Page descriptions from markdown frontmatter
+
+**Adding descriptions**: To add descriptions to pages, add YAML frontmatter:
+
+```yaml
+---
+title: Page Title
+description: Short description of what this page covers
+---
+```
+
+For examples, add frontmatter to the `README.md` files in the repository root (e.g., `examples/single-node-training/trl/README.md`).
+
+#### 4. Skills discovery
+
+The build creates `.well-known/skills/` directory structure for skills discovery:
+- Reads `skills/dstack/SKILL.md`
+- Parses name and description from frontmatter
+- Generates `.well-known/skills/index.json`
+- Copies SKILL.md to both `.well-known/skills/dstack/` and site root
+
+### File structure
+
+```
+docs/
+├── docs/ # Main documentation content
+│ ├── index.md # Getting started
+│ ├── installation.md
+│ ├── quickstart.md
+│ ├── concepts/ # Concept pages
+│ ├── guides/ # How-to guides
+│ └── reference/ # API reference (schema expansion)
+├── examples/ # Example stub files (index.md)
+│ └── single-node-training/
+│ └── trl/
+│ └── index.md # Stub referencing root README
+└── overrides/ # Theme customization
+
+examples/ # Example content (repository root)
+└── single-node-training/
+ └── trl/
+ ├── README.md # Actual content with frontmatter
+ └── train.dstack.yml
+
+scripts/docs/
+├── hooks.py # MkDocs build hooks
+├── gen_llms_files.py # llms.txt generation
+├── gen_schema_reference.py # Schema expansion
+└── gen_cli_reference.py # CLI reference generation
+
+skills/
+└── dstack/
+ └── SKILL.md # Skills discovery content
+```
+
+### Testing changes
+
+When modifying the build system:
+
+1. Test local build: `uv run mkdocs build -s`
+2. Check generated files in `site/`:
+ - `site/llms.txt`
+ - `site/llms-full.txt`
+ - `site/.well-known/skills/index.json`
+3. Verify example pages render correctly
+4. Check that descriptions appear in llms.txt
diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/docs/blog/posts/0_20.md b/docs/blog/posts/0_20.md
index 02c088e3e6..550cb60ad4 100644
--- a/docs/blog/posts/0_20.md
+++ b/docs/blog/posts/0_20.md
@@ -121,7 +121,7 @@ While the update introduces breaking changes, 0.19.* CLIs remain compatible with
## What's next
-1. Follow the [Installation](../../docs/installation/index.md) guide
+1. Follow the [Installation](../../docs/installation.md) guide
2. Try the [Quickstart](../../docs/quickstart.md)
3. Report issues on [GitHub](https://github.com/dstackai/dstack/issues)
4. Ask questions on [Discord](https://discord.gg/u8SmfwPpMd)
diff --git a/docs/blog/posts/digitalocean-and-amd-dev-cloud.md b/docs/blog/posts/digitalocean-and-amd-dev-cloud.md
index ce400899d6..7893356f4c 100644
--- a/docs/blog/posts/digitalocean-and-amd-dev-cloud.md
+++ b/docs/blog/posts/digitalocean-and-amd-dev-cloud.md
@@ -71,7 +71,7 @@ $ dstack server
-For more details, see [Installation](../../docs/installation/index.md).
+For more details, see [Installation](../../docs/installation.md).
Use the `dstack` CLI to
manage [dev environments](../../docs/concepts/dev-environments.md), [tasks](../../docs/concepts/tasks.md),
diff --git a/docs/blog/posts/dstack-sky.md b/docs/blog/posts/dstack-sky.md
index 78d35641c0..6919372024 100644
--- a/docs/blog/posts/dstack-sky.md
+++ b/docs/blog/posts/dstack-sky.md
@@ -69,7 +69,7 @@ Continue? [y/n]:
!!! info "Backends"
- `dstack Sky` supports the same [backends](../../docs/installation/index.md) as the open-source version, except that you
+ `dstack Sky` supports the same [backends](../../docs/installation.md) as the open-source version, except that you
don't need to set them up. By default, it uses all supported backends.
You can use both on-demand and spot instances without needing to manage quotas, as they are automatically handled
diff --git a/docs/blog/posts/hotaisle.md b/docs/blog/posts/hotaisle.md
index 928d0cf9c6..0d531710d5 100644
--- a/docs/blog/posts/hotaisle.md
+++ b/docs/blog/posts/hotaisle.md
@@ -80,7 +80,7 @@ $ dstack server
-For more details, see [Installation](../../docs/installation/index.md).
+For more details, see [Installation](../../docs/installation.md).
Use the `dstack` CLI to
manage [dev environments](../../docs/concepts/dev-environments.md), [tasks](../../docs/concepts/tasks.md),
diff --git a/docs/blog/posts/nebius.md b/docs/blog/posts/nebius.md
index c7a280971a..5fab310227 100644
--- a/docs/blog/posts/nebius.md
+++ b/docs/blog/posts/nebius.md
@@ -77,7 +77,7 @@ $ dstack server
-For more details, refer to [Installation](../../docs/installation/index.md).
+For more details, refer to [Installation](../../docs/installation.md).
Use the `dstack` CLI to
manage [dev environments](../../docs/concepts/dev-environments.md), [tasks](../../docs/concepts/tasks.md),
diff --git a/docs/blog/posts/nvidia-and-amd-on-vultr.md b/docs/blog/posts/nvidia-and-amd-on-vultr.md
index ed75607d47..512d316f8b 100644
--- a/docs/blog/posts/nvidia-and-amd-on-vultr.md
+++ b/docs/blog/posts/nvidia-and-amd-on-vultr.md
@@ -65,7 +65,7 @@ projects:
-For more details, refer to [Installation](../../docs/installation/index.md).
+For more details, refer to [Installation](../../docs/installation.md).
> Interested in fine-tuning or deploying DeepSeek on Vultr? Check out the corresponding [example](../../examples/llms/deepseek/index.md).
diff --git a/docs/docs/concepts/backends.md b/docs/docs/concepts/backends.md
index 572d4e0411..b601969337 100644
--- a/docs/docs/concepts/backends.md
+++ b/docs/docs/concepts/backends.md
@@ -1,3 +1,8 @@
+---
+title: Backends
+description: Configuring cloud providers and Kubernetes clusters
+---
+
# Backends
Backends allow `dstack` to provision fleets across GPU clouds or Kubernetes clusters.
diff --git a/docs/docs/concepts/dev-environments.md b/docs/docs/concepts/dev-environments.md
index bda3406a61..81781d49af 100644
--- a/docs/docs/concepts/dev-environments.md
+++ b/docs/docs/concepts/dev-environments.md
@@ -1,9 +1,14 @@
+---
+title: Dev environments
+description: Provisioning remote instances for cloud-based development
+---
+
# Dev environments
A dev environment lets you provision an instance and access it with your desktop IDE.
??? info "Prerequisites"
- Before running a dev environment, make sure you’ve [installed](../installation/index.md) the server and CLI, and created a [fleet](fleets.md).
+ Before running a dev environment, make sure you’ve [installed](../installation.md) the server and CLI, and created a [fleet](fleets.md).
## Apply a configuration
diff --git a/docs/docs/concepts/events.md b/docs/docs/concepts/events.md
index fb6e0938a2..d4057715a2 100644
--- a/docs/docs/concepts/events.md
+++ b/docs/docs/concepts/events.md
@@ -1,3 +1,8 @@
+---
+title: Events
+description: Auditing resource state changes and operations
+---
+
# Events
Events provide a chronological record of notable state changes and operations affecting `dstack` resources. They are designed for auditing, debugging, and understanding the lifecycle of runs, jobs, fleets, and other resources.
diff --git a/docs/docs/concepts/fleets.md b/docs/docs/concepts/fleets.md
index dad06cb80a..02308a649a 100644
--- a/docs/docs/concepts/fleets.md
+++ b/docs/docs/concepts/fleets.md
@@ -1,3 +1,8 @@
+---
+title: Fleets
+description: Managing pools of compute instances
+---
+
# Fleets
Before submitting runs, you must create a fleet. Fleets act as both pools of instances and templates for how those instances are provisioned.
diff --git a/docs/docs/concepts/gateways.md b/docs/docs/concepts/gateways.md
index 7402bc5762..55573bd747 100644
--- a/docs/docs/concepts/gateways.md
+++ b/docs/docs/concepts/gateways.md
@@ -1,3 +1,8 @@
+---
+title: Gateways
+description: Managing ingress traffic and endpoints for services
+---
+
# Gateways
Gateways manage ingress traffic for running [services](services.md), handle auto-scaling and rate limits, enable HTTPS, and allow you to configure a custom domain. They also support custom routers, such as the [SGLang Model Gateway](https://docs.sglang.ai/advanced_features/router.html#).
diff --git a/docs/docs/concepts/metrics.md b/docs/docs/concepts/metrics.md
index 394bb59d4b..3bebef4c09 100644
--- a/docs/docs/concepts/metrics.md
+++ b/docs/docs/concepts/metrics.md
@@ -1,3 +1,8 @@
+---
+title: Metrics
+description: Tracking and monitoring system metrics
+---
+
# Metrics
`dstack` automatically tracks essential metrics, which you can access via the CLI and UI.
diff --git a/docs/docs/concepts/projects.md b/docs/docs/concepts/projects.md
index a67e3d0569..a7e7454079 100644
--- a/docs/docs/concepts/projects.md
+++ b/docs/docs/concepts/projects.md
@@ -1,3 +1,8 @@
+---
+title: Projects
+description: Organizing teams and isolating resources
+---
+
# Projects
Projects enable the isolation of different teams and their resources. Each project can configure its own backends and
diff --git a/docs/docs/concepts/secrets.md b/docs/docs/concepts/secrets.md
index d541f281a6..0528e10c09 100644
--- a/docs/docs/concepts/secrets.md
+++ b/docs/docs/concepts/secrets.md
@@ -1,3 +1,8 @@
+---
+title: Secrets
+description: Managing sensitive values and credentials
+---
+
# Secrets
Secrets allow centralized management of sensitive values such as API keys and credentials. They are project-scoped, managed by project admins, and can be referenced in run configurations to pass sensitive values to runs in a secure manner.
diff --git a/docs/docs/concepts/services.md b/docs/docs/concepts/services.md
index 0f6bf07bb8..fe5830f356 100644
--- a/docs/docs/concepts/services.md
+++ b/docs/docs/concepts/services.md
@@ -1,9 +1,14 @@
+---
+title: Services
+description: Deploying models and web apps as endpoints
+---
+
# Services
Services allow you to deploy models or web apps as secure and scalable endpoints.
??? info "Prerequisites"
- Before running a service, make sure you’ve [installed](../installation/index.md) the server and CLI, and created a [fleet](fleets.md).
+ Before running a service, make sure you’ve [installed](../installation.md) the server and CLI, and created a [fleet](fleets.md).
## Apply a configuration
diff --git a/docs/docs/concepts/tasks.md b/docs/docs/concepts/tasks.md
index 6f3f2fabb7..dd4a83c62c 100644
--- a/docs/docs/concepts/tasks.md
+++ b/docs/docs/concepts/tasks.md
@@ -1,9 +1,14 @@
+---
+title: Tasks
+description: Running commands for training and batch processing
+---
+
# Tasks
A task allows you to run arbitrary commands on one or more nodes. They are best suited for jobs like training or batch processing.
??? info "Prerequisites"
- Before running a task, make sure you’ve [installed](../installation/index.md) the server and CLI, and created a [fleet](fleets.md).
+ Before running a task, make sure you’ve [installed](../installation.md) the server and CLI, and created a [fleet](fleets.md).
## Apply a configuration
diff --git a/docs/docs/concepts/volumes.md b/docs/docs/concepts/volumes.md
index fa5d73c714..e6d6c4cf91 100644
--- a/docs/docs/concepts/volumes.md
+++ b/docs/docs/concepts/volumes.md
@@ -1,3 +1,8 @@
+---
+title: Volumes
+description: Managing persistent data storage
+---
+
# Volumes
Volumes enable data persistence between runs of dev environments, tasks, and services.
diff --git a/docs/docs/guides/protips.md b/docs/docs/guides/protips.md
index dfb7abf0b6..a68d43f3d3 100644
--- a/docs/docs/guides/protips.md
+++ b/docs/docs/guides/protips.md
@@ -1,3 +1,8 @@
+---
+title: Protips
+description: Tips and tricks for using dstack efficiently
+---
+
# Protips
Below are tips and tricks to use `dstack` more efficiently.
diff --git a/docs/docs/guides/server-deployment.md b/docs/docs/guides/server-deployment.md
index 42365452aa..966c4c7cd2 100644
--- a/docs/docs/guides/server-deployment.md
+++ b/docs/docs/guides/server-deployment.md
@@ -1,3 +1,8 @@
+---
+title: Server Deployment
+description: Deploying the dstack server
+---
+
The `dstack` server can run on your laptop or any environment with access to the cloud and on-prem clusters you plan to use.
The minimum hardware requirements for running the server are 1 CPU and 1GB of RAM.
diff --git a/docs/docs/guides/troubleshooting.md b/docs/docs/guides/troubleshooting.md
index 2b4356fb5c..87169081f9 100644
--- a/docs/docs/guides/troubleshooting.md
+++ b/docs/docs/guides/troubleshooting.md
@@ -1,3 +1,8 @@
+---
+title: Troubleshooting
+description: Common issues and how to resolve them
+---
+
# Troubleshooting
## Reporting issues
diff --git a/docs/docs/guides/upgrade.md b/docs/docs/guides/upgrade.md
index aacf473fd8..220c6b7475 100644
--- a/docs/docs/guides/upgrade.md
+++ b/docs/docs/guides/upgrade.md
@@ -1,3 +1,8 @@
+---
+title: Upgrade
+description: Upgrading to newer versions of dstack
+---
+
# Upgrade guide
diff --git a/docs/docs/index.md b/docs/docs/index.md
index 121a379150..8afc24fdb5 100644
--- a/docs/docs/index.md
+++ b/docs/docs/index.md
@@ -1,3 +1,8 @@
+---
+title: What is dstack?
+description: Introduction to dstack and how it works
+---
+
# What is dstack?
`dstack` is a unified control plane for GPU provisioning and orchestration that works with any GPU cloud, Kubernetes, or on-prem clusters.
@@ -13,7 +18,7 @@ It streamlines development, training, and inference, and is compatible with any
#### 1. Set up the server
-> Before using `dstack`, ensure you've [installed](installation/index.md) the server, or signed up for [dstack Sky](https://sky.dstack.ai).
+> Before using `dstack`, ensure you've [installed](installation.md) the server, or signed up for [dstack Sky](https://sky.dstack.ai).
#### 2. Define configurations
@@ -35,7 +40,7 @@ Apply the configuration either via the `dstack apply` CLI command (or through a
port-forwarding, ingress, and more.
!!! info "Where do I start?"
- 1. Proceed to [installation](installation/index.md)
+ 1. Proceed to [installation](installation.md)
2. See [quickstart](quickstart.md)
3. Browse [examples](/examples)
4. Join [Discord](https://discord.gg/u8SmfwPpMd)
diff --git a/docs/docs/installation/index.md b/docs/docs/installation.md
similarity index 87%
rename from docs/docs/installation/index.md
rename to docs/docs/installation.md
index e179a8e663..725611f08e 100644
--- a/docs/docs/installation/index.md
+++ b/docs/docs/installation.md
@@ -1,3 +1,8 @@
+---
+title: Installation
+description: How to install the dstack server and CLI
+---
+
# Installation
-To verify that both the server and CLI are properly configured, use the [`dstack offer`](../reference/cli/dstack/offer.md#list-gpu-offers) command to list available GPU offers. If you don't see valid offers, ensure you've set up [backends](../concepts/backends.md).
+
??? info "Shell autocompletion"
@@ -195,8 +200,8 @@ To verify that both the server and CLI are properly configured, use the [`dstack
> `autoload -Uz compinit && compinit`.
!!! info "What's next?"
- 1. See [Backends](../concepts/backends.md)
- 2. Follow [Quickstart](../quickstart.md)
- 3. Check the [server deployment](../guides/server-deployment.md) guide
- 4. Browse [examples](../../examples.md)
+ 1. See [Backends](concepts/backends.md)
+ 2. Follow [Quickstart](quickstart.md)
+ 3. Check the [server deployment](guides/server-deployment.md) guide
+ 4. Browse [examples](../examples.md)
5. Join the community via [Discord](https://discord.gg/u8SmfwPpMd)
diff --git a/docs/docs/quickstart.md b/docs/docs/quickstart.md
index 2a8d3f0610..19331b5787 100644
--- a/docs/docs/quickstart.md
+++ b/docs/docs/quickstart.md
@@ -1,7 +1,12 @@
+---
+title: Quickstart
+description: Quick guide to creating fleets and submitting runs
+---
+
# Quickstart
!!! info "Prerequsites"
- Before using `dstack`, ensure you've [installed](installation/index.md) the server and the CLI.
+ Before using `dstack`, ensure you've [installed](installation.md) the server and the CLI.
## Create a fleet
diff --git a/docs/docs/reference/api/rest/openapi.json b/docs/docs/reference/api/rest/openapi.json
new file mode 100644
index 0000000000..c0055e0884
--- /dev/null
+++ b/docs/docs/reference/api/rest/openapi.json
@@ -0,0 +1 @@
+{"openapi": "3.1.0", "info": {"title": "OpenAPI Spec", "version": "0.0.0"}, "servers": [{"url": "http://localhost:3000", "description": "Local server"}, {"url": "https://sky.dstack.ai", "description": "Managed server"}], "paths": {"/api/server/get_info": {"post": {"tags": ["server"], "summary": "Get Server Info", "operationId": "get_server_info_api_server_get_info_post", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServerInfoRequest"}}}}}}}, "/api/users/list": {"post": {"tags": ["users"], "summary": "List Users", "description": "Returns users visible to the user, sorted by descending `created_at`.\n\nAdmins see all non-deleted users. Non-admins only see themselves.\n\nThe results are paginated. To get the next page, pass `created_at` and `id` of\nthe last user from the previous page as `prev_created_at` and `prev_id`.", "operationId": "list_users_api_users_list_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListUsersRequestRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"anyOf": [{"items": {"$ref": "#/components/schemas/UserRequest"}, "type": "array"}, {"$ref": "#/components/schemas/UsersInfoListRequest"}], "title": "Response List Users Api Users List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/users/get_my_user": {"post": {"tags": ["users"], "summary": "Get My User", "operationId": "get_my_user_api_users_get_my_user_post", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserWithCredsRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/users/get_user": {"post": {"tags": ["users"], "summary": "Get User", "operationId": "get_user_api_users_get_user_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetUserRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserWithCredsRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/users/create": {"post": {"tags": ["users"], "summary": "Create User", "operationId": "create_user_api_users_create_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateUserRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/users/update": {"post": {"tags": ["users"], "summary": "Update User", "operationId": "update_user_api_users_update_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateUserRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/users/refresh_ssh_key": {"post": {"tags": ["users"], "summary": "Refresh Ssh Key", "operationId": "refresh_ssh_key_api_users_refresh_ssh_key_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshTokenRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserWithCredsRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/users/refresh_token": {"post": {"tags": ["users"], "summary": "Refresh Token", "operationId": "refresh_token_api_users_refresh_token_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshTokenRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserWithCredsRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/users/delete": {"post": {"tags": ["users"], "summary": "Delete Users", "operationId": "delete_users_api_users_delete_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteUsersRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/auth/list_providers": {"post": {"tags": ["auth"], "summary": "List Providers", "description": "Returns OAuth2 providers registered on the server.", "operationId": "list_providers_api_auth_list_providers_post", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/OAuthProviderInfoRequest"}, "type": "array", "title": "Response List Providers Api Auth List Providers Post"}}}}}}}, "/api/auth/get_next_redirect": {"post": {"tags": ["auth"], "summary": "Get Next Redirect", "description": "A helper endpoint that returns the next redirect URL in case the state encodes it.\nCan be used by the UI after the redirect from the provider\nto determine if the user needs to be redirected further (CLI login)\nor the auth callback endpoint needs to be called directly (UI login).", "operationId": "get_next_redirect_api_auth_get_next_redirect_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OAuthGetNextRedirectRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OAuthGetNextRedirectResponseRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/projects/list": {"post": {"tags": ["projects"], "summary": "List Projects", "description": "Returns projects visible to the user.\n\nReturns all accessible projects (member projects for regular users, all non-deleted\nprojects for global admins, plus public projects if `include_not_joined` is `True`).\n\n`members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them.", "operationId": "list_projects_api_projects_list_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListProjectsRequestRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"anyOf": [{"items": {"$ref": "#/components/schemas/ProjectRequest"}, "type": "array"}, {"$ref": "#/components/schemas/ProjectsInfoListRequest"}], "title": "Response List Projects Api Projects List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/projects/list_only_no_fleets": {"post": {"tags": ["projects"], "summary": "List Only No Fleets", "description": "Returns only projects where the user is a member and that have no active fleets,\nsorted by ascending `created_at`.\n\nActive fleets are those with `deleted == False`. Projects with deleted fleets\n(but no active fleets) are included.\n\n`members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them.", "operationId": "list_only_no_fleets_api_projects_list_only_no_fleets_post", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ProjectRequest"}, "type": "array", "title": "Response List Only No Fleets Api Projects List Only No Fleets Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/projects/create": {"post": {"tags": ["projects"], "summary": "Create Project", "operationId": "create_project_api_projects_create_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateProjectRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/projects/delete": {"post": {"tags": ["projects"], "summary": "Delete Projects", "operationId": "delete_projects_api_projects_delete_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteProjectsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/projects/{project_name}/get": {"post": {"tags": ["projects"], "summary": "Get Project", "operationId": "get_project_api_projects__project_name__get_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/projects/{project_name}/set_members": {"post": {"tags": ["projects"], "summary": "Set Project Members", "operationId": "set_project_members_api_projects__project_name__set_members_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SetProjectMembersRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/projects/{project_name}/add_members": {"post": {"tags": ["projects"], "summary": "Add Project Members", "operationId": "add_project_members_api_projects__project_name__add_members_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AddProjectMemberRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/projects/{project_name}/remove_members": {"post": {"tags": ["projects"], "summary": "Remove Project Members", "operationId": "remove_project_members_api_projects__project_name__remove_members_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RemoveProjectMemberRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/projects/{project_name}/update": {"post": {"tags": ["projects"], "summary": "Update Project", "operationId": "update_project_api_projects__project_name__update_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateProjectRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/backends/list_types": {"post": {"tags": ["backends"], "summary": "List Backend Types", "operationId": "list_backend_types_api_backends_list_types_post", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/BackendType"}, "type": "array"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}}}}, "/api/project/{project_name}/backends/create": {"post": {"tags": ["backends"], "summary": "Create Backend", "operationId": "create_backend_api_project__project_name__backends_create_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/AWSBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/AzureBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CloudRiftBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CudoBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VerdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/BaseDigitalOceanBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/GCPBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/HotAisleBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/KubernetesBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/LambdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/OCIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/NebiusBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/RunpodBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/TensorDockBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VastAIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VultrBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/DstackBackendConfigRequest"}], "title": "Body"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/AWSBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/AzureBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CloudRiftBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CudoBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VerdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/BaseDigitalOceanBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/GCPBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/HotAisleBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/KubernetesBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/LambdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/OCIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/NebiusBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/RunpodBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/TensorDockBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VastAIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VultrBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/DstackBackendConfigRequest"}], "title": "Response Create Backend Api Project Project Name Backends Create Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/backends/update": {"post": {"tags": ["backends"], "summary": "Update Backend", "operationId": "update_backend_api_project__project_name__backends_update_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/AWSBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/AzureBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CloudRiftBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CudoBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VerdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/BaseDigitalOceanBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/GCPBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/HotAisleBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/KubernetesBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/LambdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/OCIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/NebiusBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/RunpodBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/TensorDockBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VastAIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VultrBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/DstackBackendConfigRequest"}], "title": "Body"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/AWSBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/AzureBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CloudRiftBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CudoBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VerdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/BaseDigitalOceanBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/GCPBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/HotAisleBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/KubernetesBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/LambdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/OCIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/NebiusBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/RunpodBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/TensorDockBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VastAIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VultrBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/DstackBackendConfigRequest"}], "title": "Response Update Backend Api Project Project Name Backends Update Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/backends/delete": {"post": {"tags": ["backends"], "summary": "Delete Backends", "operationId": "delete_backends_api_project__project_name__backends_delete_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteBackendsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/backends/{backend_name}/config_info": {"post": {"tags": ["backends"], "summary": "Get Backend Config Info", "operationId": "get_backend_config_info_api_project__project_name__backends__backend_name__config_info_post", "parameters": [{"required": true, "schema": {"$ref": "#/components/schemas/BackendType"}, "name": "backend_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/AWSBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/AzureBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CloudRiftBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/CudoBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VerdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/BaseDigitalOceanBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/GCPBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/HotAisleBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/KubernetesBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/LambdaBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/OCIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/NebiusBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/RunpodBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/TensorDockBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VastAIBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/VultrBackendConfigWithCredsRequest"}, {"$ref": "#/components/schemas/DstackBackendConfigRequest"}], "title": "Response Get Backend Config Info Api Project Project Name Backends Backend Name Config Info Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/backends/create_yaml": {"post": {"tags": ["backends"], "summary": "Create Backend Yaml", "operationId": "create_backend_yaml_api_project__project_name__backends_create_yaml_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateBackendYAMLRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/backends/update_yaml": {"post": {"tags": ["backends"], "summary": "Update Backend Yaml", "operationId": "update_backend_yaml_api_project__project_name__backends_update_yaml_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateBackendYAMLRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/backends/{backend_name}/get_yaml": {"post": {"tags": ["backends"], "summary": "Get Backend Yaml", "operationId": "get_backend_yaml_api_project__project_name__backends__backend_name__get_yaml_post", "parameters": [{"required": true, "schema": {"$ref": "#/components/schemas/BackendType"}, "name": "backend_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BackendInfoYAMLRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/fleets/list": {"post": {"tags": ["fleets"], "summary": "List Fleets", "description": "Returns all fleets and instances within them visible to user sorted by descending `created_at`.\n`project_name` and `only_active` can be specified as filters.\nIncludes only active fleet instances. To list all fleet instances, use `/api/instances/list`.\n\nThe results are paginated. To get the next page, pass `created_at` and `id` of\nthe last fleet from the previous page as `prev_created_at` and `prev_id`.", "operationId": "list_fleets_api_fleets_list_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListFleetsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/FleetRequest"}, "type": "array", "title": "Response List Fleets Api Fleets List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/fleets/list": {"post": {"tags": ["fleets"], "summary": "List Project Fleets", "description": "Returns all fleets in the project.\nIncludes only active fleet instances. To list all fleet instances, use `/api/instances/list`.", "operationId": "list_project_fleets_api_project__project_name__fleets_list_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/FleetRequest"}, "type": "array", "title": "Response List Project Fleets Api Project Project Name Fleets List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/fleets/get": {"post": {"tags": ["fleets"], "summary": "Get Fleet", "description": "Returns a fleet given `name` or `id`.\nIf given `name`, does not return deleted fleets.\nIf given `id`, returns deleted fleets.", "operationId": "get_fleet_api_project__project_name__fleets_get_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetFleetRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FleetRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/fleets/get_plan": {"post": {"tags": ["fleets"], "summary": "Get Plan", "description": "Returns a fleet plan for the given fleet configuration.", "operationId": "get_plan_api_project__project_name__fleets_get_plan_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetFleetPlanRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FleetPlanRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/fleets/apply": {"post": {"tags": ["fleets"], "summary": "Apply Plan", "description": "Creates a new fleet or updates an existing fleet.\nErrors if the expected current resource from the plan does not match the current resource.\nUse `force: true` to apply even if the current resource does not match.", "operationId": "apply_plan_api_project__project_name__fleets_apply_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApplyFleetPlanRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FleetRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/fleets/create": {"post": {"tags": ["fleets"], "summary": "Create Fleet", "description": "Creates a fleet given a fleet configuration.", "operationId": "create_fleet_api_project__project_name__fleets_create_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateFleetRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FleetRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "deprecated": true, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/fleets/delete": {"post": {"tags": ["fleets"], "summary": "Delete Fleets", "description": "Deletes one or more fleets.", "operationId": "delete_fleets_api_project__project_name__fleets_delete_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteFleetsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/fleets/delete_instances": {"post": {"tags": ["fleets"], "summary": "Delete Fleet Instances", "description": "Deletes one or more instances within the fleet.", "operationId": "delete_fleet_instances_api_project__project_name__fleets_delete_instances_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteFleetInstancesRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/instances/list": {"post": {"tags": ["instances"], "summary": "List Instances", "description": "Returns all instances visible to user sorted by descending `created_at`.\n`project_names` and `fleet_ids` can be specified as filters.\n\nThe results are paginated. To get the next page, pass `created_at` and `id` of\nthe last instance from the previous page as `prev_created_at` and `prev_id`.", "operationId": "list_instances_api_instances_list_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListInstancesRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/InstanceRequest"}, "type": "array", "title": "Response List Instances Api Instances List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/instances/get_instance_health_checks": {"post": {"tags": ["instances"], "summary": "Get Instance Health Checks", "operationId": "get_instance_health_checks_api_project__project_name__instances_get_instance_health_checks_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetInstanceHealthChecksRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetInstanceHealthChecksResponseRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/repos/list": {"post": {"tags": ["repos"], "summary": "List Repos", "operationId": "list_repos_api_project__project_name__repos_list_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/RepoHeadRequest"}, "type": "array", "title": "Response List Repos Api Project Project Name Repos List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/repos/get": {"post": {"tags": ["repos"], "summary": "Get Repo", "operationId": "get_repo_api_project__project_name__repos_get_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetRepoRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RepoHeadWithCredsRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/repos/init": {"post": {"tags": ["repos"], "summary": "Init Repo", "description": "Creates or updates a repo with the repo info and repo creds.\nRuns belong to repos, so this endpoint must be called before applying run configurations.\nYou can create `virtual` repos if you don't use git repos.", "operationId": "init_repo_api_project__project_name__repos_init_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SaveRepoCredsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/repos/delete": {"post": {"tags": ["repos"], "summary": "Delete Repos", "operationId": "delete_repos_api_project__project_name__repos_delete_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteReposRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/repos/upload_code": {"post": {"tags": ["repos"], "summary": "Upload Code", "operationId": "upload_code_api_project__project_name__repos_upload_code_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Repo Id"}, "name": "repo_id", "in": "query"}], "requestBody": {"content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_code_api_project__project_name__repos_upload_code_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/runs/list": {"post": {"tags": ["runs"], "summary": "List Runs", "description": "Returns all runs visible to user sorted by descending `submitted_at`.\n`project_name`, `repo_id`, `username`, and `only_active` can be specified as filters.\nSetting `only_active` to `true` excludes finished runs and deleted runs.\nSpecifying `repo_id` without `project_name` returns no runs.\n\nThe results are paginated. To get the next page, pass `submitted_at` and `id` of\nthe last run from the previous page as `prev_submitted_at` and `prev_run_id`.", "operationId": "list_runs_api_runs_list_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListRunsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/RunRequest"}, "type": "array", "title": "Response List Runs Api Runs List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/runs/get": {"post": {"tags": ["runs"], "summary": "Get Run", "description": "Returns a run given `run_name` or `id`.\nIf given `run_name`, does not return deleted runs.\nIf given `id`, returns deleted runs.", "operationId": "get_run_api_project__project_name__runs_get_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetRunRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RunRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/runs/get_plan": {"post": {"tags": ["runs"], "summary": "Get Plan", "description": "Returns a run plan for the given run spec.\nThis is an optional step before calling `/apply`.", "operationId": "get_plan_api_project__project_name__runs_get_plan_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetRunPlanRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RunPlanRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/runs/apply": {"post": {"tags": ["runs"], "summary": "Apply Plan", "description": "Creates a new run or updates an existing run.\nErrors if the expected current resource from the plan does not match the current resource.\nUse `force: true` to apply even if the current resource does not match.\nIf the existing run is active and cannot be updated, it must be stopped first.", "operationId": "apply_plan_api_project__project_name__runs_apply_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApplyRunPlanRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RunRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/runs/stop": {"post": {"tags": ["runs"], "summary": "Stop Runs", "description": "Stop one or more runs.", "operationId": "stop_runs_api_project__project_name__runs_stop_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StopRunsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/runs/delete": {"post": {"tags": ["runs"], "summary": "Delete Runs", "description": "Delete one or more runs. The runs must be stopped before they can be deleted.", "operationId": "delete_runs_api_project__project_name__runs_delete_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteRunsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/runs/submit": {"post": {"tags": ["runs"], "summary": "Submit Run", "operationId": "submit_run_api_project__project_name__runs_submit_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SubmitRunRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RunRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "deprecated": true, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/gpus/list": {"post": {"tags": ["gpus"], "summary": "List Gpus", "operationId": "list_gpus_api_project__project_name__gpus_list_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGpusRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGpusResponseRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/metrics/job/{run_name}": {"get": {"tags": ["metrics"], "summary": "Get Job Metrics", "description": "Returns job-level metrics such as hardware utilization\ngiven `run_name`, `run_id`, `replica_num`, and `job_num`.\nIf only `run_name` is specified, returns metrics of `(replica_num=0, job_num=0)`\nof the latest run with the given name.\nBy default, returns one latest sample. To control time window/number of samples, use\n`limit`, `after`, `before`.\n\nSupported metrics (all optional):\n* `cpus_detected_num`\n* `cpu_usage_percent`\n* `memory_total_bytes`\n* `memory_usage_bytes`\n* `memory_working_set_bytes`\n* `gpus_detected_num`\n* `gpu_memory_total_bytes`\n* `gpu_memory_usage_bytes_gpu{i}`\n* `gpu_util_percent_gpu{i}`", "operationId": "get_job_metrics_api_project__project_name__metrics_job__run_name__get", "parameters": [{"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": false, "schema": {"type": "string", "format": "uuid", "title": "Run Id"}, "name": "run_id", "in": "query"}, {"required": false, "schema": {"type": "integer", "title": "Replica Num", "default": 0}, "name": "replica_num", "in": "query"}, {"required": false, "schema": {"type": "integer", "title": "Job Num", "default": 0}, "name": "job_num", "in": "query"}, {"required": false, "schema": {"type": "integer", "title": "Limit", "default": 1}, "name": "limit", "in": "query"}, {"required": false, "schema": {"type": "string", "format": "date-time", "title": "After"}, "name": "after", "in": "query"}, {"required": false, "schema": {"type": "string", "format": "date-time", "title": "Before"}, "name": "before", "in": "query"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobMetricsRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/logs/poll": {"post": {"tags": ["logs"], "summary": "Poll Logs", "operationId": "poll_logs_api_project__project_name__logs_poll_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PollLogsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobSubmissionLogsRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/secrets/list": {"post": {"tags": ["secrets"], "summary": "List Secrets", "operationId": "list_secrets_api_project__project_name__secrets_list_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/SecretRequest"}, "type": "array", "title": "Response List Secrets Api Project Project Name Secrets List Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/secrets/get": {"post": {"tags": ["secrets"], "summary": "Get Secret", "operationId": "get_secret_api_project__project_name__secrets_get_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetSecretRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SecretRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/secrets/create_or_update": {"post": {"tags": ["secrets"], "summary": "Create Or Update Secret", "operationId": "create_or_update_secret_api_project__project_name__secrets_create_or_update_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateOrUpdateSecretRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SecretRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/secrets/delete": {"post": {"tags": ["secrets"], "summary": "Delete Secrets", "operationId": "delete_secrets_api_project__project_name__secrets_delete_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteSecretsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/gateways/list": {"post": {"tags": ["gateways"], "summary": "List Gateways", "operationId": "list_gateways_api_project__project_name__gateways_list_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/GatewayRequest"}, "type": "array", "title": "Response List Gateways Api Project Project Name Gateways List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/gateways/get": {"post": {"tags": ["gateways"], "summary": "Get Gateway", "operationId": "get_gateway_api_project__project_name__gateways_get_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetGatewayRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GatewayRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/gateways/create": {"post": {"tags": ["gateways"], "summary": "Create Gateway", "operationId": "create_gateway_api_project__project_name__gateways_create_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateGatewayRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GatewayRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/gateways/delete": {"post": {"tags": ["gateways"], "summary": "Delete Gateways", "operationId": "delete_gateways_api_project__project_name__gateways_delete_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteGatewaysRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/gateways/set_default": {"post": {"tags": ["gateways"], "summary": "Set Default Gateway", "operationId": "set_default_gateway_api_project__project_name__gateways_set_default_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SetDefaultGatewayRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/gateways/set_wildcard_domain": {"post": {"tags": ["gateways"], "summary": "Set Gateway Wildcard Domain", "operationId": "set_gateway_wildcard_domain_api_project__project_name__gateways_set_wildcard_domain_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SetWildcardDomainRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GatewayRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/volumes/list": {"post": {"tags": ["volumes"], "summary": "List Volumes", "description": "Returns all volumes visible to user sorted by descending `created_at`.\n`project_name` and `only_active` can be specified as filters.\n\nThe results are paginated. To get the next page, pass `created_at` and `id` of\nthe last fleet from the previous page as `prev_created_at` and `prev_id`.", "operationId": "list_volumes_api_volumes_list_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListVolumesRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/VolumeRequest"}, "type": "array", "title": "Response List Volumes Api Volumes List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/volumes/list": {"post": {"tags": ["volumes"], "summary": "List Project Volumes", "description": "Returns all volumes in the project.", "operationId": "list_project_volumes_api_project__project_name__volumes_list_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/VolumeRequest"}, "type": "array", "title": "Response List Project Volumes Api Project Project Name Volumes List Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/volumes/get": {"post": {"tags": ["volumes"], "summary": "Get Volume", "description": "Returns a volume given a volume name.", "operationId": "get_volume_api_project__project_name__volumes_get_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetVolumeRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/volumes/create": {"post": {"tags": ["volumes"], "summary": "Create Volume", "description": "Creates a volume given a volume configuration.", "operationId": "create_volume_api_project__project_name__volumes_create_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateVolumeRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/project/{project_name}/volumes/delete": {"post": {"tags": ["volumes"], "summary": "Delete Volumes", "description": "Deletes one or more volumes.", "operationId": "delete_volumes_api_project__project_name__volumes_delete_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeleteVolumesRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/proxy/services/{project_name}/{run_name}": {"get": {"tags": ["service-proxy"], "summary": "Redirect To Service Root", "operationId": "redirect_to_service_root_proxy_services__project_name___run_name__get", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["service-proxy"], "summary": "Redirect To Service Root", "operationId": "redirect_to_service_root_proxy_services__project_name___run_name__put", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["service-proxy"], "summary": "Redirect To Service Root", "operationId": "redirect_to_service_root_proxy_services__project_name___run_name__post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["service-proxy"], "summary": "Redirect To Service Root", "operationId": "redirect_to_service_root_proxy_services__project_name___run_name__delete", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "head": {"tags": ["service-proxy"], "summary": "Redirect To Service Root", "operationId": "redirect_to_service_root_proxy_services__project_name___run_name__head", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["service-proxy"], "summary": "Redirect To Service Root", "operationId": "redirect_to_service_root_proxy_services__project_name___run_name__patch", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/proxy/services/{project_name}/{run_name}/{path}": {"get": {"tags": ["service-proxy"], "summary": "Service Reverse Proxy", "operationId": "service_reverse_proxy_proxy_services__project_name___run_name___path__get", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Path"}, "name": "path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}, "put": {"tags": ["service-proxy"], "summary": "Service Reverse Proxy", "operationId": "service_reverse_proxy_proxy_services__project_name___run_name___path__put", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Path"}, "name": "path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}, "post": {"tags": ["service-proxy"], "summary": "Service Reverse Proxy", "operationId": "service_reverse_proxy_proxy_services__project_name___run_name___path__post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Path"}, "name": "path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}, "delete": {"tags": ["service-proxy"], "summary": "Service Reverse Proxy", "operationId": "service_reverse_proxy_proxy_services__project_name___run_name___path__delete", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Path"}, "name": "path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}, "options": {"tags": ["service-proxy"], "summary": "Service Reverse Proxy", "operationId": "service_reverse_proxy_proxy_services__project_name___run_name___path__options", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Path"}, "name": "path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}, "head": {"tags": ["service-proxy"], "summary": "Service Reverse Proxy", "operationId": "service_reverse_proxy_proxy_services__project_name___run_name___path__head", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Path"}, "name": "path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}, "patch": {"tags": ["service-proxy"], "summary": "Service Reverse Proxy", "operationId": "service_reverse_proxy_proxy_services__project_name___run_name___path__patch", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Run Name"}, "name": "run_name", "in": "path"}, {"required": true, "schema": {"type": "string", "title": "Path"}, "name": "path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/proxy/models/{project_name}/models": {"get": {"tags": ["model-proxy"], "summary": "Get Models", "operationId": "get_models_proxy_models__project_name__models_get", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ModelsResponseRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/proxy/models/{project_name}/chat/completions": {"post": {"tags": ["model-proxy"], "summary": "Post Chat Completions", "operationId": "post_chat_completions_proxy_models__project_name__chat_completions_post", "parameters": [{"required": true, "schema": {"type": "string", "title": "Project Name"}, "name": "project_name", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatCompletionsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatCompletionsResponseRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/metrics": {"get": {"tags": ["prometheus"], "summary": "Get Prometheus Metrics", "operationId": "get_prometheus_metrics_metrics_get", "responses": {"200": {"description": "Successful Response", "content": {"text/plain": {"schema": {"type": "string"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/files/get_archive_by_hash": {"post": {"tags": ["files"], "summary": "Get Archive By Hash", "operationId": "get_archive_by_hash_api_files_get_archive_by_hash_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetFileArchiveByHashRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FileArchiveRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/files/upload_archive": {"post": {"tags": ["files"], "summary": "Upload Archive", "operationId": "upload_archive_api_files_upload_archive_post", "requestBody": {"content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_archive_api_files_upload_archive_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FileArchiveRequest"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/api/events/list": {"post": {"tags": ["events"], "summary": "List Events", "description": "Returns events visible to the current user.\n\nRegular users can see events related to themselves and to projects they are members of.\nGlobal admins can see all events.\n\nThe results are paginated. To get the next page, pass `recorded_at` and `id` of\nthe last event from the previous page as `prev_recorded_at` and `prev_id`.", "operationId": "list_events_api_events_list_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListEventsRequestRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/EventRequest"}, "type": "array", "title": "Response List Events Api Events List Post"}}}}, "400": {"description": "Bad request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BadRequestErrorModelRequest"}}}}, "403": {"description": "Access denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDeniedErrorModelRequest"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"HTTPBearer": []}]}}, "/healthcheck": {"get": {"summary": "Healthcheck", "operationId": "healthcheck_healthcheck_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"ACMGatewayCertificateRequest": {"properties": {"type": {"type": "string", "enum": ["acm"], "title": "Type", "description": "Certificates by AWS Certificate Manager (ACM)", "default": "acm"}, "arn": {"type": "string", "title": "Arn", "description": "The ARN of the wildcard ACM certificate for the domain"}}, "additionalProperties": false, "type": "object", "required": ["arn"], "title": "ACMGatewayCertificateRequest"}, "AWSAccessKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["access_key"], "title": "Type", "description": "The type of credentials", "default": "access_key"}, "access_key": {"type": "string", "title": "Access Key", "description": "The access key"}, "secret_key": {"type": "string", "title": "Secret Key", "description": "The secret key"}}, "additionalProperties": false, "type": "object", "required": ["access_key", "secret_key"], "title": "AWSAccessKeyCredsRequest"}, "AWSBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["aws"], "title": "Type", "description": "The type of the backend", "default": "aws"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of AWS regions. Omit to use all regions"}, "vpc_name": {"type": "string", "title": "Vpc Name", "description": "The name of custom VPCs. All configured regions must have a VPC with this name. If your custom VPCs don't have names or have different names in different regions, use `vpc_ids` instead."}, "vpc_ids": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Vpc Ids", "description": "The mapping from AWS regions to VPC IDs. If `default_vpcs: true`, omitted regions will use default VPCs"}, "default_vpcs": {"type": "boolean", "title": "Default Vpcs", "description": "A flag to enable/disable using default VPCs in regions not configured by `vpc_ids`. Set to `false` if default VPCs should never be used. Defaults to `true`"}, "public_ips": {"type": "boolean", "title": "Public Ips", "description": "A flag to enable/disable public IP assigning on instances. `public_ips: false` requires at least one private subnet with outbound internet connectivity provided by a NAT Gateway or a Transit Gateway. Defaults to `true`"}, "iam_instance_profile": {"type": "string", "title": "Iam Instance Profile", "description": "The name of the IAM instance profile to associate with EC2 instances. You can also specify the IAM role name for roles created via the AWS console. AWS automatically creates an instance profile and gives it the same name as the role"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The tags that will be assigned to resources created by `dstack`"}, "os_images": {"allOf": [{"$ref": "#/components/schemas/AWSOSImageConfigRequest"}], "title": "Os Images", "description": "The mapping of instance categories (CPU, NVIDIA GPU) to AMI configurations"}}, "additionalProperties": false, "type": "object", "title": "AWSBackendConfigRequest"}, "AWSBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["aws"], "title": "Type", "description": "The type of the backend", "default": "aws"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of AWS regions. Omit to use all regions"}, "vpc_name": {"type": "string", "title": "Vpc Name", "description": "The name of custom VPCs. All configured regions must have a VPC with this name. If your custom VPCs don't have names or have different names in different regions, use `vpc_ids` instead."}, "vpc_ids": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Vpc Ids", "description": "The mapping from AWS regions to VPC IDs. If `default_vpcs: true`, omitted regions will use default VPCs"}, "default_vpcs": {"type": "boolean", "title": "Default Vpcs", "description": "A flag to enable/disable using default VPCs in regions not configured by `vpc_ids`. Set to `false` if default VPCs should never be used. Defaults to `true`"}, "public_ips": {"type": "boolean", "title": "Public Ips", "description": "A flag to enable/disable public IP assigning on instances. `public_ips: false` requires at least one private subnet with outbound internet connectivity provided by a NAT Gateway or a Transit Gateway. Defaults to `true`"}, "iam_instance_profile": {"type": "string", "title": "Iam Instance Profile", "description": "The name of the IAM instance profile to associate with EC2 instances. You can also specify the IAM role name for roles created via the AWS console. AWS automatically creates an instance profile and gives it the same name as the role"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The tags that will be assigned to resources created by `dstack`"}, "os_images": {"allOf": [{"$ref": "#/components/schemas/AWSOSImageConfigRequest"}], "title": "Os Images", "description": "The mapping of instance categories (CPU, NVIDIA GPU) to AMI configurations"}, "creds": {"oneOf": [{"$ref": "#/components/schemas/AWSAccessKeyCredsRequest"}, {"$ref": "#/components/schemas/AWSDefaultCredsRequest"}], "title": "Creds", "description": "The credentials", "discriminator": {"propertyName": "type", "mapping": {"access_key": "#/components/schemas/AWSAccessKeyCredsRequest", "default": "#/components/schemas/AWSDefaultCredsRequest"}}}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "AWSBackendConfigWithCredsRequest"}, "AWSDefaultCredsRequest": {"properties": {"type": {"type": "string", "enum": ["default"], "title": "Type", "description": "The type of credentials", "default": "default"}}, "additionalProperties": false, "type": "object", "title": "AWSDefaultCredsRequest"}, "AWSOSImageConfigRequest": {"properties": {"cpu": {"allOf": [{"$ref": "#/components/schemas/AWSOSImageRequest"}], "title": "Cpu", "description": "The AMI used for CPU instances"}, "nvidia": {"allOf": [{"$ref": "#/components/schemas/AWSOSImageRequest"}], "title": "Nvidia", "description": "The AMI used for NVIDIA GPU instances"}}, "additionalProperties": false, "type": "object", "title": "AWSOSImageConfigRequest"}, "AWSOSImageRequest": {"properties": {"name": {"type": "string", "title": "Name", "description": "The AMI name"}, "owner": {"type": "string", "pattern": "^(\\d{12}|self)$", "title": "Owner", "description": "The AMI owner, account ID or `self`", "default": "self"}, "user": {"type": "string", "title": "User", "description": "The OS user for provisioning"}}, "additionalProperties": false, "type": "object", "required": ["name", "user"], "title": "AWSOSImageRequest"}, "AcceleratorVendor": {"type": "string", "enum": ["nvidia", "amd", "google", "intel", "tenstorrent"], "title": "AcceleratorVendor", "description": "An enumeration."}, "AccessDeniedDetailsModelRequest": {"properties": {"code": {"type": "string", "title": "Code"}, "msg": {"type": "string", "title": "Msg", "default": "Access denied"}}, "additionalProperties": false, "type": "object", "title": "AccessDeniedDetailsModelRequest"}, "AccessDeniedErrorModelRequest": {"properties": {"detail": {"$ref": "#/components/schemas/AccessDeniedDetailsModelRequest"}}, "additionalProperties": false, "type": "object", "required": ["detail"], "title": "AccessDeniedErrorModelRequest"}, "AddProjectMemberRequestRequest": {"properties": {"members": {"items": {"$ref": "#/components/schemas/MemberSettingRequest"}, "type": "array", "title": "Members"}}, "additionalProperties": false, "type": "object", "required": ["members"], "title": "AddProjectMemberRequestRequest"}, "AppSpecRequest": {"properties": {"port": {"type": "integer", "title": "Port"}, "map_to_port": {"type": "integer", "title": "Map To Port"}, "app_name": {"type": "string", "title": "App Name"}, "url_path": {"type": "string", "title": "Url Path"}, "url_query_params": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Url Query Params"}}, "additionalProperties": false, "type": "object", "required": ["port", "app_name"], "title": "AppSpecRequest"}, "ApplyAction": {"type": "string", "enum": ["create", "update"], "title": "ApplyAction", "description": "An enumeration."}, "ApplyFleetPlanInputRequest": {"properties": {"spec": {"$ref": "#/components/schemas/FleetSpecRequest"}, "current_resource": {"allOf": [{"$ref": "#/components/schemas/FleetRequest"}], "title": "Current Resource", "description": "The expected current resource. If the resource has changed, the apply fails unless `force: true`."}}, "additionalProperties": false, "type": "object", "required": ["spec"], "title": "ApplyFleetPlanInputRequest"}, "ApplyFleetPlanRequestRequest": {"properties": {"plan": {"$ref": "#/components/schemas/ApplyFleetPlanInputRequest"}, "force": {"type": "boolean", "title": "Force", "description": "Use `force: true` to apply even if the expected resource does not match."}}, "additionalProperties": false, "type": "object", "required": ["plan", "force"], "title": "ApplyFleetPlanRequestRequest"}, "ApplyRunPlanInputRequest": {"properties": {"run_spec": {"$ref": "#/components/schemas/RunSpecRequest"}, "current_resource": {"allOf": [{"$ref": "#/components/schemas/RunRequest"}], "title": "Current Resource", "description": "The expected current resource. If the resource has changed, the apply fails unless `force: true`."}}, "additionalProperties": false, "type": "object", "required": ["run_spec"], "title": "ApplyRunPlanInputRequest"}, "ApplyRunPlanRequestRequest": {"properties": {"plan": {"$ref": "#/components/schemas/ApplyRunPlanInputRequest"}, "force": {"type": "boolean", "title": "Force", "description": "Use `force: true` to apply even if the expected resource does not match."}}, "additionalProperties": false, "type": "object", "required": ["plan", "force"], "title": "ApplyRunPlanRequestRequest"}, "AzureBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["azure"], "title": "Type", "description": "The type of the backend", "default": "azure"}, "tenant_id": {"type": "string", "title": "Tenant Id", "description": "The tenant ID"}, "subscription_id": {"type": "string", "title": "Subscription Id", "description": "The subscription ID"}, "resource_group": {"type": "string", "title": "Resource Group", "description": "The resource group for resources created by `dstack`. If not specified, `dstack` will create a new resource group"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Azure regions (locations). Omit to use all regions"}, "vpc_ids": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Vpc Ids", "description": "The mapping from configured Azure locations to network IDs. A network ID must have a format `networkResourceGroup/networkName` If not specified, `dstack` will create a new network for every configured region"}, "public_ips": {"type": "boolean", "title": "Public Ips", "description": "A flag to enable/disable public IP assigning on instances. `public_ips: false` requires `vpc_ids` that specifies custom networks with outbound internet connectivity provided by NAT Gateway or other mechanism. Defaults to `true`"}, "vm_managed_identity": {"type": "string", "title": "Vm Managed Identity", "description": "The managed identity to associate with provisioned VMs. Must have a format `managedIdentityResourceGroup/managedIdentityName`"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The tags that will be assigned to resources created by `dstack`"}}, "additionalProperties": false, "type": "object", "required": ["tenant_id", "subscription_id"], "title": "AzureBackendConfigRequest"}, "AzureBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["azure"], "title": "Type", "description": "The type of the backend", "default": "azure"}, "tenant_id": {"type": "string", "title": "Tenant Id", "description": "The tenant ID"}, "subscription_id": {"type": "string", "title": "Subscription Id", "description": "The subscription ID"}, "resource_group": {"type": "string", "title": "Resource Group", "description": "The resource group for resources created by `dstack`. If not specified, `dstack` will create a new resource group"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Azure regions (locations). Omit to use all regions"}, "vpc_ids": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Vpc Ids", "description": "The mapping from configured Azure locations to network IDs. A network ID must have a format `networkResourceGroup/networkName` If not specified, `dstack` will create a new network for every configured region"}, "public_ips": {"type": "boolean", "title": "Public Ips", "description": "A flag to enable/disable public IP assigning on instances. `public_ips: false` requires `vpc_ids` that specifies custom networks with outbound internet connectivity provided by NAT Gateway or other mechanism. Defaults to `true`"}, "vm_managed_identity": {"type": "string", "title": "Vm Managed Identity", "description": "The managed identity to associate with provisioned VMs. Must have a format `managedIdentityResourceGroup/managedIdentityName`"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The tags that will be assigned to resources created by `dstack`"}, "creds": {"oneOf": [{"$ref": "#/components/schemas/AzureClientCredsRequest"}, {"$ref": "#/components/schemas/AzureDefaultCredsRequest"}], "title": "Creds", "description": "The credentials", "discriminator": {"propertyName": "type", "mapping": {"client": "#/components/schemas/AzureClientCredsRequest", "default": "#/components/schemas/AzureDefaultCredsRequest"}}}}, "additionalProperties": false, "type": "object", "required": ["tenant_id", "subscription_id", "creds"], "title": "AzureBackendConfigWithCredsRequest"}, "AzureClientCredsRequest": {"properties": {"type": {"type": "string", "enum": ["client"], "title": "Type", "description": "The type of credentials", "default": "client"}, "client_id": {"type": "string", "title": "Client Id", "description": "The client ID"}, "client_secret": {"type": "string", "title": "Client Secret", "description": "The client secret"}, "tenant_id": {"type": "string", "title": "Tenant Id"}}, "additionalProperties": false, "type": "object", "required": ["client_id", "client_secret"], "title": "AzureClientCredsRequest"}, "AzureDefaultCredsRequest": {"properties": {"type": {"type": "string", "enum": ["default"], "title": "Type", "description": "The type of credentials", "default": "default"}}, "additionalProperties": false, "type": "object", "title": "AzureDefaultCredsRequest"}, "BackendInfoRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "config": {"anyOf": [{"$ref": "#/components/schemas/AWSBackendConfigRequest"}, {"$ref": "#/components/schemas/AzureBackendConfigRequest"}, {"$ref": "#/components/schemas/CloudRiftBackendConfigRequest"}, {"$ref": "#/components/schemas/CudoBackendConfigRequest"}, {"$ref": "#/components/schemas/BaseDigitalOceanBackendConfigRequest"}, {"$ref": "#/components/schemas/GCPBackendConfigRequest"}, {"$ref": "#/components/schemas/HotAisleBackendConfigRequest"}, {"$ref": "#/components/schemas/KubernetesBackendConfigRequest"}, {"$ref": "#/components/schemas/LambdaBackendConfigRequest"}, {"$ref": "#/components/schemas/NebiusBackendConfigRequest"}, {"$ref": "#/components/schemas/OCIBackendConfigRequest"}, {"$ref": "#/components/schemas/RunpodBackendConfigRequest"}, {"$ref": "#/components/schemas/TensorDockBackendConfigRequest"}, {"$ref": "#/components/schemas/VastAIBackendConfigRequest"}, {"$ref": "#/components/schemas/VerdaBackendConfigRequest"}, {"$ref": "#/components/schemas/VultrBackendConfigRequest"}, {"$ref": "#/components/schemas/DstackBackendConfigRequest"}, {"$ref": "#/components/schemas/DstackBaseBackendConfigRequest"}], "title": "Config"}}, "additionalProperties": false, "type": "object", "required": ["name", "config"], "title": "BackendInfoRequest"}, "BackendInfoYAMLRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "config_yaml": {"type": "string", "title": "Config Yaml"}}, "additionalProperties": false, "type": "object", "required": ["name", "config_yaml"], "title": "BackendInfoYAMLRequest"}, "BackendType": {"type": "string", "enum": ["amddevcloud", "aws", "azure", "cloudrift", "cudo", "datacrunch", "digitalocean", "dstack", "gcp", "hotaisle", "kubernetes", "lambda", "local", "remote", "nebius", "oci", "runpod", "tensordock", "vastai", "verda", "vultr"], "title": "BackendType", "description": "Attributes:\n AMDDEVCLOUD (BackendType): AMD Developer Cloud\n AWS (BackendType): Amazon Web Services\n AZURE (BackendType): Microsoft Azure\n CLOUDRIFT (BackendType): CloudRift\n CUDO (BackendType): Cudo\n DATACRUNCH (BackendType): DataCrunch (for backward compatibility)\n DIGITALOCEAN (BackendType): DigitalOcean\n DSTACK (BackendType): dstack Sky\n GCP (BackendType): Google Cloud Platform\n HOTAISLE (BackendType): Hot Aisle\n KUBERNETES (BackendType): Kubernetes\n LAMBDA (BackendType): Lambda Cloud\n NEBIUS (BackendType): Nebius AI Cloud\n OCI (BackendType): Oracle Cloud Infrastructure\n RUNPOD (BackendType): Runpod Cloud\n TENSORDOCK (BackendType): TensorDock Marketplace\n VASTAI (BackendType): Vast.ai Marketplace\n VERDA (BackendType): Verda Cloud\n VULTR (BackendType): Vultr"}, "BadRequestDetailsModelRequest": {"properties": {"code": {"allOf": [{"$ref": "#/components/schemas/ServerClientErrorCode"}], "default": "error"}, "msg": {"type": "string", "title": "Msg"}}, "additionalProperties": false, "type": "object", "required": ["msg"], "title": "BadRequestDetailsModelRequest"}, "BadRequestErrorModelRequest": {"properties": {"detail": {"$ref": "#/components/schemas/BadRequestDetailsModelRequest"}}, "additionalProperties": false, "type": "object", "required": ["detail"], "title": "BadRequestErrorModelRequest"}, "BaseDigitalOceanAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The API key"}}, "additionalProperties": false, "type": "object", "required": ["api_key"], "title": "BaseDigitalOceanAPIKeyCredsRequest"}, "BaseDigitalOceanBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["amddevcloud", "digitalocean"], "title": "Type", "description": "The type of backend"}, "project_name": {"type": "string", "title": "Project Name", "description": "The name of the project"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of regions. Omit to use all regions"}}, "additionalProperties": false, "type": "object", "required": ["type"], "title": "BaseDigitalOceanBackendConfigRequest"}, "BaseDigitalOceanBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["amddevcloud", "digitalocean"], "title": "Type", "description": "The type of backend"}, "project_name": {"type": "string", "title": "Project Name", "description": "The name of the project"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of regions. Omit to use all regions"}, "creds": {"allOf": [{"$ref": "#/components/schemas/BaseDigitalOceanAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["type", "creds"], "title": "BaseDigitalOceanBackendConfigWithCredsRequest"}, "Body_upload_archive_api_files_upload_archive_post": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_archive_api_files_upload_archive_post"}, "Body_upload_code_api_project__project_name__repos_upload_code_post": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_code_api_project__project_name__repos_upload_code_post"}, "CPUArchitecture": {"type": "string", "enum": ["x86", "arm"], "title": "CPUArchitecture", "description": "An enumeration."}, "CPUSpecRequest": {"properties": {"arch": {"allOf": [{"$ref": "#/components/schemas/CPUArchitecture"}], "description": "The CPU architecture, one of: `x86`, `arm`"}, "count": {"anyOf": [{"$ref": "#/components/schemas/Range_int_"}, {"type": "integer"}, {"type": "string"}], "title": "Count", "description": "The number of CPU cores", "default": {"min": 2}}}, "additionalProperties": false, "type": "object", "title": "CPUSpecRequest"}, "ChatCompletionsChoiceRequest": {"properties": {"finish_reason": {"type": "string", "title": "Finish Reason"}, "index": {"type": "integer", "title": "Index"}, "message": {"$ref": "#/components/schemas/ChatMessageRequest"}}, "additionalProperties": false, "type": "object", "required": ["finish_reason", "index", "message"], "title": "ChatCompletionsChoiceRequest"}, "ChatCompletionsRequestRequest": {"properties": {"messages": {"items": {"$ref": "#/components/schemas/ChatMessageRequest"}, "type": "array", "title": "Messages"}, "model": {"type": "string", "title": "Model"}, "frequency_penalty": {"type": "number", "title": "Frequency Penalty", "default": 0.0}, "logit_bias": {"additionalProperties": {"type": "number"}, "type": "object", "title": "Logit Bias", "default": {}}, "max_tokens": {"type": "integer", "title": "Max Tokens"}, "n": {"type": "integer", "title": "N", "default": 1}, "presence_penalty": {"type": "number", "title": "Presence Penalty", "default": 0.0}, "response_format": {"type": "object", "title": "Response Format"}, "seed": {"type": "integer", "title": "Seed"}, "stop": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "string"}], "title": "Stop"}, "stream": {"type": "boolean", "title": "Stream", "default": false}, "temperature": {"type": "number", "title": "Temperature", "default": 1.0}, "top_p": {"type": "number", "title": "Top P", "default": 1.0}, "tools": {"items": {}, "type": "array", "title": "Tools", "default": []}, "tool_choice": {"anyOf": [{"type": "string", "enum": ["none", "auto"]}, {"type": "object"}], "title": "Tool Choice", "default": {}}, "user": {"type": "string", "title": "User"}}, "additionalProperties": false, "type": "object", "required": ["messages", "model"], "title": "ChatCompletionsRequestRequest"}, "ChatCompletionsResponseRequest": {"properties": {"id": {"type": "string", "title": "Id"}, "choices": {"items": {"$ref": "#/components/schemas/ChatCompletionsChoiceRequest"}, "type": "array", "title": "Choices"}, "created": {"type": "integer", "title": "Created"}, "model": {"type": "string", "title": "Model"}, "system_fingerprint": {"type": "string", "title": "System Fingerprint", "default": ""}, "object": {"type": "string", "enum": ["chat.completion"], "title": "Object", "default": "chat.completion"}, "usage": {"$ref": "#/components/schemas/ChatCompletionsUsageRequest"}}, "additionalProperties": false, "type": "object", "required": ["id", "choices", "created", "model", "usage"], "title": "ChatCompletionsResponseRequest"}, "ChatCompletionsUsageRequest": {"properties": {"completion_tokens": {"type": "integer", "title": "Completion Tokens"}, "prompt_tokens": {"type": "integer", "title": "Prompt Tokens"}, "total_tokens": {"type": "integer", "title": "Total Tokens"}}, "additionalProperties": false, "type": "object", "required": ["completion_tokens", "prompt_tokens", "total_tokens"], "title": "ChatCompletionsUsageRequest"}, "ChatMessageRequest": {"properties": {"role": {"type": "string", "title": "Role"}, "content": {"type": "string", "title": "Content"}}, "additionalProperties": false, "type": "object", "required": ["role", "content"], "title": "ChatMessageRequest"}, "CloudRiftAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The API key"}}, "additionalProperties": false, "type": "object", "required": ["api_key"], "title": "CloudRiftAPIKeyCredsRequest"}, "CloudRiftBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["cloudrift"], "title": "Type", "description": "The type of backend", "default": "cloudrift"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of CloudRift regions. Omit to use all regions"}}, "additionalProperties": false, "type": "object", "title": "CloudRiftBackendConfigRequest"}, "CloudRiftBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["cloudrift"], "title": "Type", "description": "The type of backend", "default": "cloudrift"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of CloudRift regions. Omit to use all regions"}, "creds": {"allOf": [{"$ref": "#/components/schemas/CloudRiftAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "CloudRiftBackendConfigWithCredsRequest"}, "CreateBackendYAMLRequestRequest": {"properties": {"config_yaml": {"type": "string", "title": "Config Yaml"}}, "additionalProperties": false, "type": "object", "required": ["config_yaml"], "title": "CreateBackendYAMLRequestRequest"}, "CreateFleetRequestRequest": {"properties": {"spec": {"$ref": "#/components/schemas/FleetSpecRequest"}}, "additionalProperties": false, "type": "object", "required": ["spec"], "title": "CreateFleetRequestRequest"}, "CreateGatewayRequestRequest": {"properties": {"configuration": {"$ref": "#/components/schemas/GatewayConfigurationRequest"}}, "additionalProperties": false, "type": "object", "required": ["configuration"], "title": "CreateGatewayRequestRequest"}, "CreateOrUpdateSecretRequestRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "value": {"type": "string", "title": "Value"}}, "additionalProperties": false, "type": "object", "required": ["name", "value"], "title": "CreateOrUpdateSecretRequestRequest"}, "CreateProjectRequestRequest": {"properties": {"project_name": {"type": "string", "title": "Project Name"}, "is_public": {"type": "boolean", "title": "Is Public", "default": false}}, "additionalProperties": false, "type": "object", "required": ["project_name"], "title": "CreateProjectRequestRequest"}, "CreateUserRequestRequest": {"properties": {"username": {"type": "string", "title": "Username"}, "global_role": {"$ref": "#/components/schemas/GlobalRole"}, "email": {"type": "string", "title": "Email"}, "active": {"type": "boolean", "title": "Active", "default": true}}, "additionalProperties": false, "type": "object", "required": ["username", "global_role"], "title": "CreateUserRequestRequest"}, "CreateVolumeRequestRequest": {"properties": {"configuration": {"$ref": "#/components/schemas/VolumeConfigurationRequest"}}, "additionalProperties": false, "type": "object", "required": ["configuration"], "title": "CreateVolumeRequestRequest"}, "CreationPolicy": {"type": "string", "enum": ["reuse", "reuse-or-create"], "title": "CreationPolicy", "description": "An enumeration."}, "CudoAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The API key"}}, "additionalProperties": false, "type": "object", "required": ["api_key"], "title": "CudoAPIKeyCredsRequest"}, "CudoBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["cudo"], "title": "Type", "description": "The type of backend", "default": "cudo"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Cudo regions. Omit to use all regions"}, "project_id": {"type": "string", "title": "Project Id", "description": "The project ID"}}, "additionalProperties": false, "type": "object", "required": ["project_id"], "title": "CudoBackendConfigRequest"}, "CudoBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["cudo"], "title": "Type", "description": "The type of backend", "default": "cudo"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Cudo regions. Omit to use all regions"}, "project_id": {"type": "string", "title": "Project Id", "description": "The project ID"}, "creds": {"allOf": [{"$ref": "#/components/schemas/CudoAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["project_id", "creds"], "title": "CudoBackendConfigWithCredsRequest"}, "DeleteBackendsRequestRequest": {"properties": {"backends_names": {"items": {"$ref": "#/components/schemas/BackendType"}, "type": "array"}}, "additionalProperties": false, "type": "object", "required": ["backends_names"], "title": "DeleteBackendsRequestRequest"}, "DeleteFleetInstancesRequestRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "instance_nums": {"items": {"type": "integer"}, "type": "array", "title": "Instance Nums"}}, "additionalProperties": false, "type": "object", "required": ["name", "instance_nums"], "title": "DeleteFleetInstancesRequestRequest"}, "DeleteFleetsRequestRequest": {"properties": {"names": {"items": {"type": "string"}, "type": "array", "title": "Names"}}, "additionalProperties": false, "type": "object", "required": ["names"], "title": "DeleteFleetsRequestRequest"}, "DeleteGatewaysRequestRequest": {"properties": {"names": {"items": {"type": "string"}, "type": "array", "title": "Names"}}, "additionalProperties": false, "type": "object", "required": ["names"], "title": "DeleteGatewaysRequestRequest"}, "DeleteProjectsRequestRequest": {"properties": {"projects_names": {"items": {"type": "string"}, "type": "array", "title": "Projects Names"}}, "additionalProperties": false, "type": "object", "required": ["projects_names"], "title": "DeleteProjectsRequestRequest"}, "DeleteReposRequestRequest": {"properties": {"repos_ids": {"items": {"type": "string"}, "type": "array", "title": "Repos Ids"}}, "additionalProperties": false, "type": "object", "required": ["repos_ids"], "title": "DeleteReposRequestRequest"}, "DeleteRunsRequestRequest": {"properties": {"runs_names": {"items": {"type": "string"}, "type": "array", "title": "Runs Names"}}, "additionalProperties": false, "type": "object", "required": ["runs_names"], "title": "DeleteRunsRequestRequest"}, "DeleteSecretsRequestRequest": {"properties": {"secrets_names": {"items": {"type": "string"}, "type": "array", "title": "Secrets Names"}}, "additionalProperties": false, "type": "object", "required": ["secrets_names"], "title": "DeleteSecretsRequestRequest"}, "DeleteUsersRequestRequest": {"properties": {"users": {"items": {"type": "string"}, "type": "array", "title": "Users"}}, "additionalProperties": false, "type": "object", "required": ["users"], "title": "DeleteUsersRequestRequest"}, "DeleteVolumesRequestRequest": {"properties": {"names": {"items": {"type": "string"}, "type": "array", "title": "Names"}}, "additionalProperties": false, "type": "object", "required": ["names"], "title": "DeleteVolumesRequestRequest"}, "DevEnvironmentConfigurationRequest": {"properties": {"ide": {"anyOf": [{"type": "string", "enum": ["vscode"]}, {"type": "string", "enum": ["cursor"]}, {"type": "string", "enum": ["windsurf"]}], "title": "Ide", "description": "The IDE to run. Supported values include `vscode`, `cursor`, and `windsurf`"}, "version": {"type": "string", "title": "Version", "description": "The version of the IDE. For `windsurf`, the version is in the format `version@commit`"}, "init": {"items": {"type": "string"}, "type": "array", "title": "Init", "description": "The shell commands to run on startup", "default": []}, "inactivity_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Inactivity Duration", "description": "The maximum amount of time the dev environment can be inactive (e.g., `2h`, `1d`, etc). After it elapses, the dev environment is automatically stopped. Inactivity is defined as the absence of SSH connections to the dev environment, including VS Code connections, `ssh ` shells, and attached `dstack apply` or `dstack attach` commands. Use `off` for unlimited duration. Can be updated in-place. Defaults to `off`"}, "ports": {"items": {"anyOf": [{"type": "integer", "maximum": 65536.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?:[0-9]+|\\*):[0-9]+$"}, {"$ref": "#/components/schemas/PortMappingRequest"}]}, "type": "array", "title": "Ports", "description": "Port numbers/mapping to expose", "default": []}, "type": {"type": "string", "enum": ["dev-environment"], "title": "Type", "default": "dev-environment"}, "name": {"type": "string", "title": "Name", "description": "The run name. If not specified, a random name is generated"}, "image": {"type": "string", "title": "Image", "description": "The name of the Docker image to run"}, "user": {"type": "string", "title": "User", "description": "The user inside the container, `user_name_or_id[:group_name_or_id]` (e.g., `ubuntu`, `1000:1000`). Defaults to the default user from the `image`"}, "privileged": {"type": "boolean", "title": "Privileged", "description": "Run the container in privileged mode", "default": false}, "entrypoint": {"type": "string", "title": "Entrypoint", "description": "The Docker entrypoint"}, "working_dir": {"type": "string", "title": "Working Dir", "description": "The absolute path to the working directory inside the container. Defaults to the `image`'s default working directory"}, "home_dir": {"type": "string", "title": "Home Dir", "default": "/root"}, "registry_auth": {"allOf": [{"$ref": "#/components/schemas/RegistryAuthRequest"}], "title": "Registry Auth", "description": "Credentials for pulling a private Docker image"}, "python": {"allOf": [{"$ref": "#/components/schemas/PythonVersion"}], "description": "The major version of Python. Mutually exclusive with `image` and `docker`"}, "nvcc": {"type": "boolean", "title": "Nvcc", "description": "Use image with NVIDIA CUDA Compiler (NVCC) included. Mutually exclusive with `image` and `docker`"}, "single_branch": {"type": "boolean", "title": "Single Branch", "description": "Whether to clone and track only the current branch or all remote branches. Relevant only when using remote Git repos. Defaults to `false` for dev environments and to `true` for tasks and services"}, "env": {"allOf": [{"$ref": "#/components/schemas/Env"}], "title": "Env", "description": "The mapping or the list of environment variables", "default": {"__root__": {}}}, "shell": {"type": "string", "title": "Shell", "description": "The shell used to run commands. Allowed values are `sh`, `bash`, or an absolute path, e.g., `/usr/bin/zsh`. Defaults to `/bin/sh` if the `image` is specified, `/bin/bash` otherwise"}, "resources": {"allOf": [{"$ref": "#/components/schemas/ResourcesSpecRequest"}], "title": "Resources", "description": "The resources requirements to run the configuration", "default": {"cpu": {"min": 2}, "memory": {"min": 8.0}, "disk": {"size": {"min": 100.0}}}}, "priority": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Priority", "description": "The priority of the run, an integer between `0` and `100`. `dstack` tries to provision runs with higher priority first. Defaults to `0`"}, "volumes": {"items": {"anyOf": [{"$ref": "#/components/schemas/VolumeMountPointRequest"}, {"$ref": "#/components/schemas/InstanceMountPointRequest"}, {"type": "string"}]}, "type": "array", "title": "Volumes", "description": "The volumes mount points", "default": []}, "docker": {"type": "boolean", "title": "Docker", "description": "Use Docker inside the container. Mutually exclusive with `image`, `python`, and `nvcc`. Overrides `privileged`"}, "repos": {"items": {"$ref": "#/components/schemas/RepoSpecRequest"}, "type": "array", "title": "Repos", "description": "The list of Git repos", "default": []}, "files": {"items": {"anyOf": [{"$ref": "#/components/schemas/FilePathMappingRequest"}, {"type": "string"}]}, "type": "array", "title": "Files", "description": "The local to container file path mappings", "default": []}, "setup": {"items": {"type": "string"}, "type": "array", "title": "Setup", "default": []}, "backends": {"items": {"$ref": "#/components/schemas/BackendType"}, "type": "array", "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)"}, "availability_zones": {"items": {"type": "string"}, "type": "array", "title": "Availability Zones", "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)"}, "instance_types": {"items": {"type": "string"}, "type": "array", "title": "Instance Types", "description": "The cloud-specific instance types to consider for provisioning (e.g., `[p3.8xlarge, n1-standard-4]`)"}, "reservation": {"type": "string", "title": "Reservation", "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations"}, "spot_policy": {"allOf": [{"$ref": "#/components/schemas/SpotPolicy"}], "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`"}, "retry": {"anyOf": [{"$ref": "#/components/schemas/ProfileRetryRequest"}, {"type": "boolean"}], "title": "Retry", "description": "The policy for resubmitting the run. Defaults to `false`"}, "max_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Max Duration", "description": "The maximum duration of a run (e.g., `2h`, `1d`, etc) in a running state, excluding provisioning and pulling. After it elapses, the run is automatically stopped. Use `off` for unlimited duration. Defaults to `off`"}, "stop_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Stop Duration", "description": "The maximum duration of a run graceful stopping. After it elapses, the run is automatically forced stopped. This includes force detaching volumes used by the run. Use `off` for unlimited duration. Defaults to `5m`"}, "max_price": {"type": "number", "exclusiveMinimum": 0.0, "title": "Max Price", "description": "The maximum instance price per hour, in dollars"}, "creation_policy": {"allOf": [{"$ref": "#/components/schemas/CreationPolicy"}], "description": "The policy for using instances from fleets: `reuse`, `reuse-or-create`. Defaults to `reuse-or-create`"}, "idle_duration": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Idle Duration", "description": "Time to wait before terminating idle instances. Instances are not terminated if the fleet is already at `nodes.min`. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration"}, "utilization_policy": {"allOf": [{"$ref": "#/components/schemas/UtilizationPolicyRequest"}], "title": "Utilization Policy", "description": "Run termination policy based on utilization"}, "startup_order": {"allOf": [{"$ref": "#/components/schemas/StartupOrder"}], "description": "The order in which master and workers jobs are started: `any`, `master-first`, `workers-first`. Defaults to `any`"}, "stop_criteria": {"allOf": [{"$ref": "#/components/schemas/StopCriteria"}], "description": "The criteria determining when a multi-node run should be considered finished: `all-done`, `master-done`. Defaults to `all-done`"}, "schedule": {"allOf": [{"$ref": "#/components/schemas/ScheduleRequest"}], "title": "Schedule", "description": "The schedule for starting the run at specified time"}, "fleets": {"items": {"type": "string"}, "type": "array", "title": "Fleets", "description": "The fleets considered for reuse"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them"}}, "additionalProperties": false, "type": "object", "required": ["ide"], "title": "DevEnvironmentConfigurationRequest"}, "DiskRequest": {"properties": {"size_mib": {"type": "integer", "title": "Size Mib"}}, "additionalProperties": false, "type": "object", "required": ["size_mib"], "title": "DiskRequest"}, "DiskSpecRequest": {"properties": {"size": {"anyOf": [{"$ref": "#/components/schemas/Range_Memory_"}, {"type": "integer"}, {"type": "string"}], "title": "Size", "description": "Disk size"}}, "additionalProperties": false, "type": "object", "required": ["size"], "title": "DiskSpecRequest"}, "DstackBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["dstack"], "title": "Type", "default": "dstack"}, "base_backends": {"items": {"type": "string"}, "type": "array", "title": "Base Backends"}}, "additionalProperties": false, "type": "object", "required": ["base_backends"], "title": "DstackBackendConfigRequest", "description": "This is a config model of DstackBackend stored in BackendModel.config and used by DstackConfigurator."}, "DstackBaseBackendConfigRequest": {"properties": {"type": {"type": "string", "title": "Type"}}, "additionalProperties": false, "type": "object", "required": ["type"], "title": "DstackBaseBackendConfigRequest"}, "Env": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"additionalProperties": {"anyOf": [{"type": "string"}, {"$ref": "#/components/schemas/EnvSentinelRequest"}]}, "type": "object"}], "title": "Env", "description": "Env represents a mapping of process environment variables, as in environ(7).\nEnvironment values may be omitted, in that case the :class:`EnvSentinel`\nobject is used as a placeholder.\n\nTo create an instance from a `dict[str, str]` or a `list[str]` use pydantic's\n:meth:`BaseModel.parse_obj(dict | list)` method.\n\nNB: this is *NOT* a CoreModel, pydantic-duality, which is used as a base\nfor the CoreModel, doesn't play well with custom root models.", "default": {}}, "EnvSentinelRequest": {"properties": {"key": {"type": "string", "title": "Key"}}, "additionalProperties": false, "type": "object", "required": ["key"], "title": "EnvSentinelRequest"}, "EventRequest": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "message": {"type": "string", "title": "Message"}, "recorded_at": {"type": "string", "format": "date-time", "title": "Recorded At"}, "actor_user_id": {"type": "string", "format": "uuid", "title": "Actor User Id", "description": "ID of the user who performed the action that triggered the event, or `null` if the action was performed by the system"}, "actor_user": {"type": "string", "title": "Actor User", "description": "Name of the user who performed the action that triggered the event, or `null` if the action was performed by the system"}, "is_actor_user_deleted": {"type": "boolean", "title": "Is Actor User Deleted", "description": "Whether the user who performed the action that triggered the event is deleted, or `null` if the action was performed by the system"}, "targets": {"items": {"$ref": "#/components/schemas/EventTargetRequest"}, "type": "array", "title": "Targets", "description": "List of entities affected by the event"}}, "additionalProperties": false, "type": "object", "required": ["id", "message", "recorded_at", "targets"], "title": "EventRequest"}, "EventTargetRequest": {"properties": {"type": {"type": "string", "title": "Type", "description": "Type of the target entity. One of: `project`, `user`, `fleet`, `instance`, `run`, `job`, `volume`, `gateway`, `secret`"}, "project_id": {"type": "string", "format": "uuid", "title": "Project Id", "description": "ID of the project the target entity belongs to, or `null` for target types not bound to a project (e.g., users)"}, "project_name": {"type": "string", "title": "Project Name", "description": "Name of the project the target entity belongs to, or `null` for target types not bound to a project (e.g., users)"}, "is_project_deleted": {"type": "boolean", "title": "Is Project Deleted", "description": "Whether the project the target entity belongs to is deleted, or `null` for target types not bound to a project (e.g., users)"}, "id": {"type": "string", "format": "uuid", "title": "Id", "description": "ID of the target entity"}, "name": {"type": "string", "title": "Name", "description": "Name of the target entity"}}, "additionalProperties": false, "type": "object", "required": ["type", "id", "name"], "title": "EventTargetRequest"}, "EventTargetType": {"type": "string", "enum": ["project", "user", "fleet", "instance", "run", "job", "volume", "gateway", "secret"], "title": "EventTargetType", "description": "An enumeration."}, "FileArchiveMappingRequest": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id", "description": "The File archive ID"}, "path": {"type": "string", "title": "Path", "description": "The path in the container"}}, "additionalProperties": false, "type": "object", "required": ["id", "path"], "title": "FileArchiveMappingRequest"}, "FileArchiveRequest": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "hash": {"type": "string", "title": "Hash"}}, "additionalProperties": false, "type": "object", "required": ["id", "hash"], "title": "FileArchiveRequest"}, "FilePathMappingRequest": {"properties": {"local_path": {"type": "string", "title": "Local Path", "description": "The path on the user's machine. Relative paths are resolved relative to the parent directory of the the configuration file"}, "path": {"type": "string", "title": "Path", "description": "The path in the container. Relative paths are resolved relative to the working directory"}}, "additionalProperties": false, "type": "object", "required": ["local_path", "path"], "title": "FilePathMappingRequest"}, "FleetConfigurationRequest": {"properties": {"type": {"type": "string", "enum": ["fleet"], "title": "Type", "default": "fleet"}, "name": {"type": "string", "title": "Name", "description": "The fleet name"}, "env": {"allOf": [{"$ref": "#/components/schemas/Env"}], "title": "Env", "description": "The mapping or the list of environment variables", "default": {"__root__": {}}}, "ssh_config": {"allOf": [{"$ref": "#/components/schemas/SSHParamsRequest"}], "title": "Ssh Config", "description": "The parameters for adding instances via SSH"}, "nodes": {"anyOf": [{"$ref": "#/components/schemas/FleetNodesSpecRequest"}, {"type": "integer"}, {"type": "string"}], "title": "Nodes", "description": "The number of instances in cloud fleet"}, "placement": {"allOf": [{"$ref": "#/components/schemas/InstanceGroupPlacement"}], "description": "The placement of instances: `any` or `cluster`"}, "reservation": {"type": "string", "title": "Reservation", "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations"}, "resources": {"allOf": [{"$ref": "#/components/schemas/ResourcesSpecRequest"}], "title": "Resources", "description": "The resources requirements", "default": {"cpu": {"min": 2}, "memory": {"min": 8.0}, "disk": {"size": {"min": 100.0}}}}, "blocks": {"anyOf": [{"type": "string", "enum": ["auto"]}, {"type": "integer", "minimum": 1.0}], "title": "Blocks", "description": "The amount of blocks to split the instance into, a number or `auto`. `auto` means as many as possible. The number of GPUs and CPUs must be divisible by the number of blocks. Defaults to `1`, i.e. do not split", "default": 1}, "backends": {"items": {"$ref": "#/components/schemas/BackendType"}, "type": "array", "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)"}, "availability_zones": {"items": {"type": "string"}, "type": "array", "title": "Availability Zones", "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)"}, "instance_types": {"items": {"type": "string"}, "type": "array", "title": "Instance Types", "description": "The cloud-specific instance types to consider for provisioning (e.g., `[p3.8xlarge, n1-standard-4]`)"}, "spot_policy": {"allOf": [{"$ref": "#/components/schemas/SpotPolicy"}], "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`"}, "retry": {"anyOf": [{"$ref": "#/components/schemas/ProfileRetryRequest"}, {"type": "boolean"}], "title": "Retry", "description": "The policy for provisioning retry. Defaults to `false`"}, "max_price": {"type": "number", "exclusiveMinimum": 0.0, "title": "Max Price", "description": "The maximum instance price per hour, in dollars"}, "idle_duration": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Idle Duration", "description": "Time to wait before terminating idle instances. Instances are not terminated if the fleet is already at `nodes.min`. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them"}}, "additionalProperties": false, "type": "object", "title": "FleetConfigurationRequest"}, "FleetNodesSpecRequest": {"properties": {"min": {"type": "integer", "title": "Min", "description": "The minimum number of instances to maintain in the fleet"}, "target": {"type": "integer", "title": "Target", "description": "The number of instances to provision on fleet apply. `min` <= `target` <= `max` Defaults to `min`"}, "max": {"type": "integer", "title": "Max", "description": "The maximum number of instances allowed in the fleet. Unlimited if not specified"}}, "additionalProperties": false, "type": "object", "required": ["min", "target"], "title": "FleetNodesSpecRequest"}, "FleetPlanRequest": {"properties": {"project_name": {"type": "string", "title": "Project Name"}, "user": {"type": "string", "title": "User"}, "spec": {"$ref": "#/components/schemas/FleetSpecRequest"}, "effective_spec": {"$ref": "#/components/schemas/FleetSpecRequest"}, "current_resource": {"$ref": "#/components/schemas/FleetRequest"}, "offers": {"items": {"$ref": "#/components/schemas/InstanceOfferWithAvailabilityRequest"}, "type": "array", "title": "Offers"}, "total_offers": {"type": "integer", "title": "Total Offers"}, "max_offer_price": {"type": "number", "title": "Max Offer Price"}, "action": {"$ref": "#/components/schemas/ApplyAction"}}, "additionalProperties": false, "type": "object", "required": ["project_name", "user", "spec", "offers", "total_offers"], "title": "FleetPlanRequest"}, "FleetRequest": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "name": {"type": "string", "title": "Name"}, "project_name": {"type": "string", "title": "Project Name"}, "spec": {"$ref": "#/components/schemas/FleetSpecRequest"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "status": {"$ref": "#/components/schemas/FleetStatus"}, "status_message": {"type": "string", "title": "Status Message"}, "instances": {"items": {"$ref": "#/components/schemas/InstanceRequest"}, "type": "array", "title": "Instances"}}, "additionalProperties": false, "type": "object", "required": ["id", "name", "project_name", "spec", "created_at", "status", "instances"], "title": "FleetRequest"}, "FleetSpecRequest": {"properties": {"configuration": {"$ref": "#/components/schemas/FleetConfigurationRequest"}, "configuration_path": {"type": "string", "title": "Configuration Path"}, "profile": {"$ref": "#/components/schemas/ProfileRequest"}, "autocreated": {"type": "boolean", "title": "Autocreated", "default": false}}, "additionalProperties": false, "type": "object", "required": ["configuration", "profile"], "title": "FleetSpecRequest"}, "FleetStatus": {"type": "string", "enum": ["submitted", "active", "terminating", "terminated", "failed"], "title": "FleetStatus", "description": "An enumeration."}, "GCPBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["gcp"], "title": "Type", "description": "The type of backend", "default": "gcp"}, "project_id": {"type": "string", "title": "Project Id", "description": "The project ID"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of GCP regions. Omit to use all regions"}, "vpc_name": {"type": "string", "title": "Vpc Name", "description": "The name of a custom VPC. If not specified, the default VPC is used"}, "extra_vpcs": {"items": {"type": "string"}, "type": "array", "title": "Extra Vpcs", "description": "The names of additional VPCs used for multi-NIC instances, such as those that support GPUDirect. Specify eight VPCs to maximize bandwidth in clusters with eight-GPU instances. Each VPC must have a subnet and a firewall rule allowing internal traffic across all subnets"}, "roce_vpcs": {"items": {"type": "string"}, "type": "array", "maxItems": 1, "title": "Roce Vpcs", "description": "The names of additional VPCs with the RoCE network profile. Used for RDMA on GPU instances that support the MRDMA interface type. A VPC should have eight subnets to maximize the bandwidth in clusters with eight-GPU instances."}, "vpc_project_id": {"type": "string", "title": "Vpc Project Id", "description": "The shared VPC hosted project ID. Required for shared VPC only"}, "public_ips": {"type": "boolean", "title": "Public Ips", "description": "A flag to enable/disable public IP assigning on instances. Defaults to `true`"}, "nat_check": {"type": "boolean", "title": "Nat Check", "description": "A flag to enable/disable a check that Cloud NAT is configured for the VPC. This should be set to `false` when `public_ips: false` and outbound internet connectivity is provided by a mechanism other than Cloud NAT such as a third-party NAT appliance. Defaults to `true`"}, "vm_service_account": {"type": "string", "title": "Vm Service Account", "description": "The service account to associate with provisioned VMs"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The tags (labels) that will be assigned to resources created by `dstack`"}, "preview_features": {"items": {"type": "string", "enum": ["g4"]}, "type": "array", "maxItems": 1, "title": "Preview Features", "description": "The list of preview GCP features to enable. There are currently no preview features"}}, "additionalProperties": false, "type": "object", "required": ["project_id"], "title": "GCPBackendConfigRequest"}, "GCPBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["gcp"], "title": "Type", "description": "The type of backend", "default": "gcp"}, "project_id": {"type": "string", "title": "Project Id", "description": "The project ID"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of GCP regions. Omit to use all regions"}, "vpc_name": {"type": "string", "title": "Vpc Name", "description": "The name of a custom VPC. If not specified, the default VPC is used"}, "extra_vpcs": {"items": {"type": "string"}, "type": "array", "title": "Extra Vpcs", "description": "The names of additional VPCs used for multi-NIC instances, such as those that support GPUDirect. Specify eight VPCs to maximize bandwidth in clusters with eight-GPU instances. Each VPC must have a subnet and a firewall rule allowing internal traffic across all subnets"}, "roce_vpcs": {"items": {"type": "string"}, "type": "array", "maxItems": 1, "title": "Roce Vpcs", "description": "The names of additional VPCs with the RoCE network profile. Used for RDMA on GPU instances that support the MRDMA interface type. A VPC should have eight subnets to maximize the bandwidth in clusters with eight-GPU instances."}, "vpc_project_id": {"type": "string", "title": "Vpc Project Id", "description": "The shared VPC hosted project ID. Required for shared VPC only"}, "public_ips": {"type": "boolean", "title": "Public Ips", "description": "A flag to enable/disable public IP assigning on instances. Defaults to `true`"}, "nat_check": {"type": "boolean", "title": "Nat Check", "description": "A flag to enable/disable a check that Cloud NAT is configured for the VPC. This should be set to `false` when `public_ips: false` and outbound internet connectivity is provided by a mechanism other than Cloud NAT such as a third-party NAT appliance. Defaults to `true`"}, "vm_service_account": {"type": "string", "title": "Vm Service Account", "description": "The service account to associate with provisioned VMs"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The tags (labels) that will be assigned to resources created by `dstack`"}, "preview_features": {"items": {"type": "string", "enum": ["g4"]}, "type": "array", "maxItems": 1, "title": "Preview Features", "description": "The list of preview GCP features to enable. There are currently no preview features"}, "creds": {"oneOf": [{"$ref": "#/components/schemas/GCPServiceAccountCredsRequest"}, {"$ref": "#/components/schemas/GCPDefaultCredsRequest"}], "title": "Creds", "description": "The credentials", "discriminator": {"propertyName": "type", "mapping": {"service_account": "#/components/schemas/GCPServiceAccountCredsRequest", "default": "#/components/schemas/GCPDefaultCredsRequest"}}}}, "additionalProperties": false, "type": "object", "required": ["project_id", "creds"], "title": "GCPBackendConfigWithCredsRequest"}, "GCPDefaultCredsRequest": {"properties": {"type": {"type": "string", "enum": ["default"], "title": "Type", "default": "default"}}, "additionalProperties": false, "type": "object", "title": "GCPDefaultCredsRequest"}, "GCPServiceAccountCredsRequest": {"properties": {"type": {"type": "string", "enum": ["service_account"], "title": "Type", "description": "The type of credentials", "default": "service_account"}, "filename": {"type": "string", "title": "Filename", "description": "The path to the service account file", "default": ""}, "data": {"type": "string", "title": "Data", "description": "The contents of the service account file"}}, "additionalProperties": false, "type": "object", "required": ["data"], "title": "GCPServiceAccountCredsRequest"}, "GPUSpecRequest": {"properties": {"vendor": {"allOf": [{"$ref": "#/components/schemas/AcceleratorVendor"}], "description": "The vendor of the GPU/accelerator, one of: `nvidia`, `amd`, `google` (alias: `tpu`), `intel`"}, "name": {"anyOf": [{"type": "array"}, {"type": "string"}], "items": {"type": "string"}, "title": "Name", "description": "The name of the GPU (e.g., `A100` or `H100`)"}, "count": {"anyOf": [{"$ref": "#/components/schemas/Range_int_"}, {"type": "integer"}, {"type": "string"}], "title": "Count", "description": "The number of GPUs", "default": {"min": 1}}, "memory": {"anyOf": [{"$ref": "#/components/schemas/Range_Memory_"}, {"type": "integer"}, {"type": "string"}], "title": "Memory", "description": "The RAM size (e.g., `16GB`). Can be set to a range (e.g. `16GB..`, or `16GB..80GB`)"}, "total_memory": {"anyOf": [{"$ref": "#/components/schemas/Range_Memory_"}, {"type": "integer"}, {"type": "string"}], "title": "Total Memory", "description": "The total RAM size (e.g., `32GB`). Can be set to a range (e.g. `16GB..`, or `16GB..80GB`)"}, "compute_capability": {"items": {}, "type": "array", "title": "Compute Capability", "description": "The minimum compute capability of the GPU (e.g., `7.5`)"}}, "additionalProperties": false, "type": "object", "title": "GPUSpecRequest"}, "GatewayConfigurationRequest": {"properties": {"type": {"type": "string", "enum": ["gateway"], "title": "Type", "default": "gateway"}, "name": {"type": "string", "title": "Name", "description": "The gateway name"}, "default": {"type": "boolean", "title": "Default", "description": "Make the gateway default", "default": false}, "backend": {"allOf": [{"$ref": "#/components/schemas/BackendType"}], "description": "The gateway backend"}, "region": {"type": "string", "title": "Region", "description": "The gateway region"}, "instance_type": {"type": "string", "minLength": 1, "title": "Instance Type", "description": "Backend-specific instance type to use for the gateway instance. Omit to use the backend's default, which is typically a small non-GPU instance"}, "router": {"allOf": [{"$ref": "#/components/schemas/SGLangRouterConfigRequest"}], "title": "Router", "description": "The router configuration"}, "domain": {"type": "string", "title": "Domain", "description": "The gateway domain, e.g. `example.com`"}, "public_ip": {"type": "boolean", "title": "Public Ip", "description": "Allocate public IP for the gateway", "default": true}, "certificate": {"anyOf": [{"$ref": "#/components/schemas/LetsEncryptGatewayCertificateRequest"}, {"$ref": "#/components/schemas/ACMGatewayCertificateRequest"}], "title": "Certificate", "description": "The SSL certificate configuration. Defaults to `type: lets-encrypt`", "default": {"type": "lets-encrypt"}}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The custom tags to associate with the gateway. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them"}}, "additionalProperties": false, "type": "object", "required": ["backend", "region"], "title": "GatewayConfigurationRequest"}, "GatewayRequest": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "name": {"type": "string", "title": "Name"}, "configuration": {"$ref": "#/components/schemas/GatewayConfigurationRequest"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "status": {"$ref": "#/components/schemas/GatewayStatus"}, "status_message": {"type": "string", "title": "Status Message"}, "hostname": {"type": "string", "title": "Hostname"}, "ip_address": {"type": "string", "title": "Ip Address"}, "instance_id": {"type": "string", "title": "Instance Id"}, "wildcard_domain": {"type": "string", "title": "Wildcard Domain"}, "default": {"type": "boolean", "title": "Default"}, "backend": {"$ref": "#/components/schemas/BackendType"}, "region": {"type": "string", "title": "Region"}}, "additionalProperties": false, "type": "object", "required": ["name", "configuration", "created_at", "status", "default"], "title": "GatewayRequest"}, "GatewayStatus": {"type": "string", "enum": ["submitted", "provisioning", "running", "failed"], "title": "GatewayStatus", "description": "An enumeration."}, "GetFileArchiveByHashRequestRequest": {"properties": {"hash": {"type": "string", "title": "Hash"}}, "additionalProperties": false, "type": "object", "required": ["hash"], "title": "GetFileArchiveByHashRequestRequest"}, "GetFleetPlanRequestRequest": {"properties": {"spec": {"$ref": "#/components/schemas/FleetSpecRequest"}}, "additionalProperties": false, "type": "object", "required": ["spec"], "title": "GetFleetPlanRequestRequest"}, "GetFleetRequestRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "id": {"type": "string", "format": "uuid", "title": "Id"}}, "additionalProperties": false, "type": "object", "title": "GetFleetRequestRequest"}, "GetGatewayRequestRequest": {"properties": {"name": {"type": "string", "title": "Name"}}, "additionalProperties": false, "type": "object", "required": ["name"], "title": "GetGatewayRequestRequest"}, "GetInstanceHealthChecksRequestRequest": {"properties": {"fleet_name": {"type": "string", "title": "Fleet Name"}, "instance_num": {"type": "integer", "title": "Instance Num"}, "after": {"type": "string", "format": "date-time", "title": "After"}, "before": {"type": "string", "format": "date-time", "title": "Before"}, "limit": {"type": "integer", "title": "Limit"}}, "additionalProperties": false, "type": "object", "required": ["fleet_name", "instance_num"], "title": "GetInstanceHealthChecksRequestRequest"}, "GetInstanceHealthChecksResponseRequest": {"properties": {"health_checks": {"items": {"$ref": "#/components/schemas/HealthCheckRequest"}, "type": "array", "title": "Health Checks"}}, "additionalProperties": false, "type": "object", "required": ["health_checks"], "title": "GetInstanceHealthChecksResponseRequest"}, "GetRepoRequestRequest": {"properties": {"repo_id": {"type": "string", "title": "Repo Id", "description": "A unique identifier of the repo"}, "include_creds": {"type": "boolean", "title": "Include Creds"}}, "additionalProperties": false, "type": "object", "required": ["repo_id", "include_creds"], "title": "GetRepoRequestRequest"}, "GetRunPlanRequestRequest": {"properties": {"run_spec": {"$ref": "#/components/schemas/RunSpecRequest"}, "max_offers": {"type": "integer", "maximum": 10000.0, "minimum": 1.0, "title": "Max Offers", "description": "The maximum number of offers to return"}}, "additionalProperties": false, "type": "object", "required": ["run_spec"], "title": "GetRunPlanRequestRequest"}, "GetRunRequestRequest": {"properties": {"run_name": {"type": "string", "title": "Run Name"}, "id": {"type": "string", "format": "uuid", "title": "Id"}}, "additionalProperties": false, "type": "object", "title": "GetRunRequestRequest"}, "GetSecretRequestRequest": {"properties": {"name": {"type": "string", "title": "Name"}}, "additionalProperties": false, "type": "object", "required": ["name"], "title": "GetSecretRequestRequest"}, "GetUserRequestRequest": {"properties": {"username": {"type": "string", "title": "Username"}}, "additionalProperties": false, "type": "object", "required": ["username"], "title": "GetUserRequestRequest"}, "GetVolumeRequestRequest": {"properties": {"name": {"type": "string", "title": "Name"}}, "additionalProperties": false, "type": "object", "required": ["name"], "title": "GetVolumeRequestRequest"}, "GlobalRole": {"type": "string", "enum": ["admin", "user"], "title": "GlobalRole", "description": "An enumeration."}, "GpuGroupRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "memory_mib": {"type": "integer", "title": "Memory Mib"}, "vendor": {"$ref": "#/components/schemas/AcceleratorVendor"}, "availability": {"items": {"$ref": "#/components/schemas/InstanceAvailability"}, "type": "array"}, "spot": {"items": {"type": "string", "enum": ["spot", "on-demand"]}, "type": "array", "title": "Spot"}, "count": {"$ref": "#/components/schemas/Range_int_"}, "price": {"$ref": "#/components/schemas/Range_float_"}, "backends": {"items": {"$ref": "#/components/schemas/BackendType"}, "type": "array"}, "backend": {"$ref": "#/components/schemas/BackendType"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions"}, "region": {"type": "string", "title": "Region"}}, "additionalProperties": false, "type": "object", "required": ["name", "memory_mib", "vendor", "availability", "spot", "count", "price"], "title": "GpuGroupRequest", "description": "GPU group that can handle all grouping scenarios."}, "GpuRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "memory_mib": {"type": "integer", "title": "Memory Mib"}, "vendor": {"$ref": "#/components/schemas/AcceleratorVendor"}}, "additionalProperties": false, "type": "object", "required": ["name", "memory_mib"], "title": "GpuRequest"}, "HTTPHeaderSpecRequest": {"properties": {"name": {"type": "string", "maxLength": 256, "minLength": 1, "title": "Name", "description": "The name of the HTTP header"}, "value": {"type": "string", "maxLength": 2048, "minLength": 1, "title": "Value", "description": "The value of the HTTP header"}}, "additionalProperties": false, "type": "object", "required": ["name", "value"], "title": "HTTPHeaderSpecRequest"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "HeaderPartitioningKeyRequest": {"properties": {"type": {"type": "string", "enum": ["header"], "title": "Type", "description": "Partitioning type", "default": "header"}, "header": {"type": "string", "maxLength": 500, "pattern": "^[a-zA-Z0-9-_]+$", "title": "Header", "description": "Name of the header to use for partitioning"}}, "additionalProperties": false, "type": "object", "required": ["header"], "title": "HeaderPartitioningKeyRequest"}, "HealthCheckRequest": {"properties": {"collected_at": {"type": "string", "format": "date-time", "title": "Collected At"}, "status": {"$ref": "#/components/schemas/HealthStatus"}, "events": {"items": {"$ref": "#/components/schemas/HealthEventRequest"}, "type": "array", "title": "Events"}}, "additionalProperties": false, "type": "object", "required": ["collected_at", "status", "events"], "title": "HealthCheckRequest"}, "HealthEventRequest": {"properties": {"timestamp": {"type": "string", "format": "date-time", "title": "Timestamp"}, "status": {"$ref": "#/components/schemas/HealthStatus"}, "message": {"type": "string", "title": "Message"}}, "additionalProperties": false, "type": "object", "required": ["timestamp", "status", "message"], "title": "HealthEventRequest"}, "HealthStatus": {"type": "string", "enum": ["healthy", "warning", "failure"], "title": "HealthStatus", "description": "An enumeration."}, "HotAisleAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The Hot Aisle API key"}}, "additionalProperties": false, "type": "object", "required": ["api_key"], "title": "HotAisleAPIKeyCredsRequest"}, "HotAisleBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["hotaisle"], "title": "Type", "description": "The type of backend", "default": "hotaisle"}, "team_handle": {"type": "string", "title": "Team Handle", "description": "The Hot Aisle team handle"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Hot Aisle regions. Omit to use all regions"}}, "additionalProperties": false, "type": "object", "required": ["team_handle"], "title": "HotAisleBackendConfigRequest"}, "HotAisleBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["hotaisle"], "title": "Type", "description": "The type of backend", "default": "hotaisle"}, "team_handle": {"type": "string", "title": "Team Handle", "description": "The Hot Aisle team handle"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Hot Aisle regions. Omit to use all regions"}, "creds": {"allOf": [{"$ref": "#/components/schemas/HotAisleAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["team_handle", "creds"], "title": "HotAisleBackendConfigWithCredsRequest"}, "IPAddressPartitioningKeyRequest": {"properties": {"type": {"type": "string", "enum": ["ip_address"], "title": "Type", "description": "Partitioning type", "default": "ip_address"}}, "additionalProperties": false, "type": "object", "title": "IPAddressPartitioningKeyRequest"}, "InstanceAvailability": {"enum": ["unknown", "available", "not_available", "no_quota", "no_balance", "idle", "busy"], "title": "InstanceAvailability", "description": "An enumeration."}, "InstanceGroupPlacement": {"type": "string", "enum": ["any", "cluster"], "title": "InstanceGroupPlacement", "description": "An enumeration."}, "InstanceMountPointRequest": {"properties": {"instance_path": {"type": "string", "title": "Instance Path", "description": "The absolute path on the instance (host)"}, "path": {"type": "string", "title": "Path", "description": "The absolute path in the container"}, "optional": {"type": "boolean", "title": "Optional", "description": "Allow running without this volume in backends that do not support instance volumes", "default": false}}, "additionalProperties": false, "type": "object", "required": ["instance_path", "path"], "title": "InstanceMountPointRequest"}, "InstanceOfferWithAvailabilityRequest": {"properties": {"backend": {"$ref": "#/components/schemas/BackendType"}, "instance": {"$ref": "#/components/schemas/InstanceTypeRequest"}, "region": {"type": "string", "title": "Region"}, "price": {"type": "number", "title": "Price"}, "backend_data": {"type": "object", "title": "Backend Data", "default": {}}, "availability": {"$ref": "#/components/schemas/InstanceAvailability"}, "availability_zones": {"items": {"type": "string"}, "type": "array", "title": "Availability Zones"}, "instance_runtime": {"allOf": [{"$ref": "#/components/schemas/InstanceRuntime"}], "default": "shim"}, "blocks": {"type": "integer", "title": "Blocks", "default": 1}, "total_blocks": {"type": "integer", "title": "Total Blocks", "default": 1}}, "additionalProperties": false, "type": "object", "required": ["backend", "instance", "region", "price", "availability"], "title": "InstanceOfferWithAvailabilityRequest"}, "InstanceRequest": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "project_name": {"type": "string", "title": "Project Name"}, "backend": {"$ref": "#/components/schemas/BackendType"}, "instance_type": {"$ref": "#/components/schemas/InstanceTypeRequest"}, "name": {"type": "string", "title": "Name"}, "fleet_id": {"type": "string", "format": "uuid", "title": "Fleet Id"}, "fleet_name": {"type": "string", "title": "Fleet Name"}, "instance_num": {"type": "integer", "title": "Instance Num"}, "job_name": {"type": "string", "title": "Job Name"}, "hostname": {"type": "string", "title": "Hostname"}, "status": {"$ref": "#/components/schemas/InstanceStatus"}, "unreachable": {"type": "boolean", "title": "Unreachable", "default": false}, "health_status": {"allOf": [{"$ref": "#/components/schemas/HealthStatus"}], "default": "healthy"}, "termination_reason": {"type": "string", "title": "Termination Reason"}, "termination_reason_message": {"type": "string", "title": "Termination Reason Message"}, "created": {"type": "string", "format": "date-time", "title": "Created"}, "region": {"type": "string", "title": "Region"}, "availability_zone": {"type": "string", "title": "Availability Zone"}, "price": {"type": "number", "title": "Price"}, "total_blocks": {"type": "integer", "title": "Total Blocks"}, "busy_blocks": {"type": "integer", "title": "Busy Blocks", "default": 0}}, "additionalProperties": false, "type": "object", "required": ["id", "project_name", "name", "instance_num", "status", "created"], "title": "InstanceRequest"}, "InstanceRuntime": {"enum": ["shim", "runner"], "title": "InstanceRuntime", "description": "An enumeration."}, "InstanceStatus": {"type": "string", "enum": ["pending", "provisioning", "idle", "busy", "terminating", "terminated"], "title": "InstanceStatus", "description": "An enumeration."}, "InstanceTypeRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "resources": {"$ref": "#/components/schemas/ResourcesRequest"}}, "additionalProperties": false, "type": "object", "required": ["name", "resources"], "title": "InstanceTypeRequest"}, "JobMetricsRequest": {"properties": {"metrics": {"items": {"$ref": "#/components/schemas/MetricRequest"}, "type": "array", "title": "Metrics"}}, "additionalProperties": false, "type": "object", "required": ["metrics"], "title": "JobMetricsRequest"}, "JobPlanRequest": {"properties": {"job_spec": {"$ref": "#/components/schemas/JobSpecRequest"}, "offers": {"items": {"$ref": "#/components/schemas/InstanceOfferWithAvailabilityRequest"}, "type": "array", "title": "Offers"}, "total_offers": {"type": "integer", "title": "Total Offers"}, "max_price": {"type": "number", "title": "Max Price"}}, "additionalProperties": false, "type": "object", "required": ["job_spec", "offers", "total_offers"], "title": "JobPlanRequest"}, "JobProvisioningDataRequest": {"properties": {"backend": {"$ref": "#/components/schemas/BackendType"}, "base_backend": {"$ref": "#/components/schemas/BackendType"}, "instance_type": {"$ref": "#/components/schemas/InstanceTypeRequest"}, "instance_id": {"type": "string", "title": "Instance Id"}, "hostname": {"type": "string", "title": "Hostname"}, "internal_ip": {"type": "string", "title": "Internal Ip"}, "public_ip_enabled": {"type": "boolean", "title": "Public Ip Enabled", "default": true}, "instance_network": {"type": "string", "title": "Instance Network"}, "region": {"type": "string", "title": "Region"}, "availability_zone": {"type": "string", "title": "Availability Zone"}, "reservation": {"type": "string", "title": "Reservation"}, "price": {"type": "number", "title": "Price"}, "username": {"type": "string", "title": "Username"}, "ssh_port": {"type": "integer", "title": "Ssh Port"}, "dockerized": {"type": "boolean", "title": "Dockerized"}, "ssh_proxy": {"$ref": "#/components/schemas/SSHConnectionParamsRequest"}, "backend_data": {"type": "string", "title": "Backend Data"}}, "additionalProperties": false, "type": "object", "required": ["backend", "instance_type", "instance_id", "region", "price", "username", "dockerized"], "title": "JobProvisioningDataRequest"}, "JobRequest": {"properties": {"job_spec": {"$ref": "#/components/schemas/JobSpecRequest"}, "job_submissions": {"items": {"$ref": "#/components/schemas/JobSubmissionRequest"}, "type": "array", "title": "Job Submissions"}}, "additionalProperties": false, "type": "object", "required": ["job_spec", "job_submissions"], "title": "JobRequest"}, "JobRuntimeDataRequest": {"properties": {"network_mode": {"$ref": "#/components/schemas/NetworkMode"}, "gpu": {"type": "integer", "title": "Gpu"}, "cpu": {"type": "number", "title": "Cpu"}, "memory": {"type": "number", "title": "Memory"}, "ports": {"additionalProperties": {"type": "integer"}, "type": "object", "title": "Ports"}, "volume_names": {"items": {"type": "string"}, "type": "array", "title": "Volume Names"}, "offer": {"$ref": "#/components/schemas/InstanceOfferWithAvailabilityRequest"}}, "additionalProperties": false, "type": "object", "required": ["network_mode"], "title": "JobRuntimeDataRequest", "description": "Holds various information only available after the job is submitted, such as:\n * offer (depends on the instance)\n * volumes used by the job\n * resource constraints for container (depend on the instance)\n * port mapping (reported by the shim only after the container is started)\n\nSome fields are mutable, for example, `ports` only available when the shim starts\nthe container."}, "JobSSHKeyRequest": {"properties": {"private": {"type": "string", "title": "Private"}, "public": {"type": "string", "title": "Public"}}, "additionalProperties": false, "type": "object", "required": ["private", "public"], "title": "JobSSHKeyRequest"}, "JobSpecRequest": {"properties": {"replica_num": {"type": "integer", "title": "Replica Num", "default": 0}, "job_num": {"type": "integer", "title": "Job Num"}, "job_name": {"type": "string", "title": "Job Name"}, "jobs_per_replica": {"type": "integer", "title": "Jobs Per Replica", "default": 1}, "replica_group": {"type": "string", "title": "Replica Group", "default": "0"}, "app_specs": {"items": {"$ref": "#/components/schemas/AppSpecRequest"}, "type": "array", "title": "App Specs"}, "user": {"$ref": "#/components/schemas/UnixUserRequest"}, "commands": {"items": {"type": "string"}, "type": "array", "title": "Commands"}, "env": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Env"}, "home_dir": {"type": "string", "title": "Home Dir"}, "image_name": {"type": "string", "title": "Image Name"}, "privileged": {"type": "boolean", "title": "Privileged", "default": false}, "single_branch": {"type": "boolean", "title": "Single Branch"}, "max_duration": {"type": "integer", "title": "Max Duration"}, "stop_duration": {"type": "integer", "title": "Stop Duration"}, "utilization_policy": {"$ref": "#/components/schemas/UtilizationPolicyRequest"}, "registry_auth": {"$ref": "#/components/schemas/RegistryAuthRequest"}, "requirements": {"$ref": "#/components/schemas/RequirementsRequest"}, "retry": {"$ref": "#/components/schemas/RetryRequest"}, "volumes": {"items": {"anyOf": [{"$ref": "#/components/schemas/VolumeMountPointRequest"}, {"$ref": "#/components/schemas/InstanceMountPointRequest"}]}, "type": "array", "title": "Volumes"}, "ssh_key": {"$ref": "#/components/schemas/JobSSHKeyRequest"}, "working_dir": {"type": "string", "title": "Working Dir"}, "repo_data": {"oneOf": [{"$ref": "#/components/schemas/RemoteRunRepoDataRequest"}, {"$ref": "#/components/schemas/LocalRunRepoDataRequest"}, {"$ref": "#/components/schemas/VirtualRunRepoDataRequest"}], "title": "Repo Data", "discriminator": {"propertyName": "repo_type", "mapping": {"remote": "#/components/schemas/RemoteRunRepoDataRequest", "local": "#/components/schemas/LocalRunRepoDataRequest", "virtual": "#/components/schemas/VirtualRunRepoDataRequest"}}}, "repo_code_hash": {"type": "string", "title": "Repo Code Hash"}, "repo_dir": {"type": "string", "title": "Repo Dir", "default": "/workflow"}, "repo_exists_action": {"$ref": "#/components/schemas/RepoExistsAction"}, "file_archives": {"items": {"$ref": "#/components/schemas/FileArchiveMappingRequest"}, "type": "array", "title": "File Archives", "default": []}, "service_port": {"type": "integer", "title": "Service Port"}, "probes": {"items": {"$ref": "#/components/schemas/ProbeSpecRequest"}, "type": "array", "title": "Probes", "default": []}}, "additionalProperties": false, "type": "object", "required": ["job_num", "job_name", "commands", "env", "image_name", "requirements"], "title": "JobSpecRequest"}, "JobStatus": {"type": "string", "enum": ["submitted", "provisioning", "pulling", "running", "terminating", "terminated", "aborted", "failed", "done"], "title": "JobStatus", "description": "An enumeration."}, "JobSubmissionLogsRequest": {"properties": {"logs": {"items": {"$ref": "#/components/schemas/LogEventRequest"}, "type": "array", "title": "Logs"}, "external_url": {"type": "string", "title": "External Url"}, "next_token": {"type": "string", "title": "Next Token"}}, "additionalProperties": false, "type": "object", "required": ["logs"], "title": "JobSubmissionLogsRequest"}, "JobSubmissionRequest": {"properties": {"id": {"type": "string", "format": "uuid4", "title": "Id"}, "submission_num": {"type": "integer", "title": "Submission Num"}, "deployment_num": {"type": "integer", "title": "Deployment Num", "default": 0}, "submitted_at": {"type": "string", "format": "date-time", "title": "Submitted At"}, "last_processed_at": {"type": "string", "format": "date-time", "title": "Last Processed At"}, "finished_at": {"type": "string", "format": "date-time", "title": "Finished At"}, "inactivity_secs": {"type": "integer", "title": "Inactivity Secs"}, "status": {"$ref": "#/components/schemas/JobStatus"}, "status_message": {"type": "string", "title": "Status Message", "default": ""}, "termination_reason": {"type": "string", "title": "Termination Reason"}, "termination_reason_message": {"type": "string", "title": "Termination Reason Message"}, "exit_status": {"type": "integer", "title": "Exit Status"}, "job_provisioning_data": {"$ref": "#/components/schemas/JobProvisioningDataRequest"}, "job_runtime_data": {"$ref": "#/components/schemas/JobRuntimeDataRequest"}, "error": {"type": "string", "title": "Error"}, "probes": {"items": {"$ref": "#/components/schemas/ProbeRequest"}, "type": "array", "title": "Probes", "default": []}}, "additionalProperties": false, "type": "object", "required": ["id", "submission_num", "submitted_at", "last_processed_at", "status"], "title": "JobSubmissionRequest"}, "KubeconfigConfigRequest": {"properties": {"filename": {"type": "string", "title": "Filename", "description": "The path to the kubeconfig file", "default": ""}, "data": {"type": "string", "title": "Data", "description": "The contents of the kubeconfig file"}}, "additionalProperties": false, "type": "object", "required": ["data"], "title": "KubeconfigConfigRequest"}, "KubernetesBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["kubernetes"], "title": "Type", "description": "The type of backend", "default": "kubernetes"}, "proxy_jump": {"allOf": [{"$ref": "#/components/schemas/KubernetesProxyJumpConfigRequest"}], "title": "Proxy Jump", "description": "The SSH proxy jump configuration"}, "namespace": {"type": "string", "title": "Namespace", "description": "The namespace for resources managed by `dstack`", "default": "default"}}, "additionalProperties": false, "type": "object", "title": "KubernetesBackendConfigRequest"}, "KubernetesBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["kubernetes"], "title": "Type", "description": "The type of backend", "default": "kubernetes"}, "proxy_jump": {"allOf": [{"$ref": "#/components/schemas/KubernetesProxyJumpConfigRequest"}], "title": "Proxy Jump", "description": "The SSH proxy jump configuration"}, "namespace": {"type": "string", "title": "Namespace", "description": "The namespace for resources managed by `dstack`", "default": "default"}, "kubeconfig": {"allOf": [{"$ref": "#/components/schemas/KubeconfigConfigRequest"}], "title": "Kubeconfig", "description": "The kubeconfig configuration"}}, "additionalProperties": false, "type": "object", "required": ["kubeconfig"], "title": "KubernetesBackendConfigWithCredsRequest"}, "KubernetesProxyJumpConfigRequest": {"properties": {"hostname": {"type": "string", "title": "Hostname", "description": "The external IP address or hostname of any node"}, "port": {"type": "integer", "title": "Port", "description": "Any port accessible outside of the cluster"}}, "additionalProperties": false, "type": "object", "title": "KubernetesProxyJumpConfigRequest"}, "LambdaAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The API key"}}, "additionalProperties": false, "type": "object", "required": ["api_key"], "title": "LambdaAPIKeyCredsRequest"}, "LambdaBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["lambda"], "title": "Type", "description": "The type of backend", "default": "lambda"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Lambda regions. Omit to use all regions"}}, "additionalProperties": false, "type": "object", "title": "LambdaBackendConfigRequest"}, "LambdaBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["lambda"], "title": "Type", "description": "The type of backend", "default": "lambda"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Lambda regions. Omit to use all regions"}, "creds": {"allOf": [{"$ref": "#/components/schemas/LambdaAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "LambdaBackendConfigWithCredsRequest"}, "LetsEncryptGatewayCertificateRequest": {"properties": {"type": {"type": "string", "enum": ["lets-encrypt"], "title": "Type", "description": "Automatic certificates by Let's Encrypt", "default": "lets-encrypt"}}, "additionalProperties": false, "type": "object", "title": "LetsEncryptGatewayCertificateRequest"}, "ListEventsRequestRequest": {"properties": {"target_projects": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Projects", "description": "List of project IDs. The response will only include events that target the specified projects"}, "target_users": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Users", "description": "List of user IDs. The response will only include events that target the specified users"}, "target_fleets": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Fleets", "description": "List of fleet IDs. The response will only include events that target the specified fleets"}, "target_instances": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Instances", "description": "List of instance IDs. The response will only include events that target the specified instances"}, "target_runs": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Runs", "description": "List of run IDs. The response will only include events that target the specified runs"}, "target_jobs": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Jobs", "description": "List of job IDs. The response will only include events that target the specified jobs"}, "target_volumes": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Volumes", "description": "List of volume IDs. The response will only include events that target the specified volumes"}, "target_gateways": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Gateways", "description": "List of gateway IDs. The response will only include events that target the specified gateways"}, "target_secrets": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Target Secrets", "description": "List of secret IDs. The response will only include events that target the specified secrets"}, "within_projects": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Within Projects", "description": "List of project IDs. The response will only include events that target the specified projects or any entities within those projects"}, "within_fleets": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Within Fleets", "description": "List of fleet IDs. The response will only include events that target the specified fleets or instances within those fleets"}, "within_runs": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Within Runs", "description": "List of run IDs. The response will only include events that target the specified runs or jobs within those runs"}, "include_target_types": {"items": {"$ref": "#/components/schemas/EventTargetType"}, "type": "array", "maxItems": 16, "minItems": 1, "description": "List of target types. The response will only include events that have a target of one of the specified types"}, "actors": {"items": {"type": "string", "format": "uuid"}, "type": "array", "maxItems": 16, "minItems": 1, "title": "Actors", "description": "List of user IDs or `null` values. The response will only include events about actions performed by the specified users, or performed by the system if `null` is specified"}, "prev_recorded_at": {"type": "string", "format": "date-time", "title": "Prev Recorded At"}, "prev_id": {"type": "string", "format": "uuid", "title": "Prev Id"}, "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "title": "Limit", "default": 100}, "ascending": {"type": "boolean", "title": "Ascending", "default": false}}, "additionalProperties": false, "type": "object", "title": "ListEventsRequestRequest"}, "ListFleetsRequestRequest": {"properties": {"project_name": {"type": "string", "title": "Project Name"}, "only_active": {"type": "boolean", "title": "Only Active", "default": false}, "prev_created_at": {"type": "string", "format": "date-time", "title": "Prev Created At"}, "prev_id": {"type": "string", "format": "uuid", "title": "Prev Id"}, "limit": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Limit", "default": 100}, "ascending": {"type": "boolean", "title": "Ascending", "default": false}}, "additionalProperties": false, "type": "object", "title": "ListFleetsRequestRequest"}, "ListGpusRequestRequest": {"properties": {"run_spec": {"$ref": "#/components/schemas/RunSpecRequest"}, "group_by": {"items": {"type": "string", "enum": ["backend", "region", "count"]}, "type": "array", "title": "Group By", "description": "List of fields to group by. Valid values: 'backend', 'region', 'count'. Note: 'region' can only be used together with 'backend'."}}, "additionalProperties": false, "type": "object", "required": ["run_spec"], "title": "ListGpusRequestRequest", "description": "Request for listing GPUs with optional grouping."}, "ListGpusResponseRequest": {"properties": {"gpus": {"items": {"$ref": "#/components/schemas/GpuGroupRequest"}, "type": "array", "title": "Gpus", "description": "List of GPU specifications, grouped according to the group_by parameter"}}, "additionalProperties": false, "type": "object", "required": ["gpus"], "title": "ListGpusResponseRequest", "description": "Response containing GPU specifications."}, "ListInstancesRequestRequest": {"properties": {"project_names": {"items": {"type": "string"}, "type": "array", "title": "Project Names"}, "fleet_ids": {"items": {"type": "string", "format": "uuid"}, "type": "array", "title": "Fleet Ids"}, "only_active": {"type": "boolean", "title": "Only Active", "default": false}, "prev_created_at": {"type": "string", "format": "date-time", "title": "Prev Created At"}, "prev_id": {"type": "string", "format": "uuid", "title": "Prev Id"}, "limit": {"type": "integer", "title": "Limit", "default": 1000}, "ascending": {"type": "boolean", "title": "Ascending", "default": false}}, "additionalProperties": false, "type": "object", "title": "ListInstancesRequestRequest"}, "ListProjectsRequestRequest": {"properties": {"include_not_joined": {"type": "boolean", "title": "Include Not Joined", "description": "Include public projects where user is not a member.", "default": true}, "return_total_count": {"type": "boolean", "title": "Return Total Count", "description": "Return `total_count` with the total number of projects.", "default": false}, "name_pattern": {"type": "string", "pattern": "^[a-zA-Z0-9-_]*$", "title": "Name Pattern", "description": "Include only projects with the name containing `name_pattern`."}, "prev_created_at": {"type": "string", "format": "date-time", "title": "Prev Created At", "description": "Paginate projects by specifying `created_at` of the last (first) project in previous batch for descending (ascending)."}, "prev_id": {"type": "string", "format": "uuid", "title": "Prev Id", "description": "Paginate projects by specifying `id` of the last (first) project in previous batch for descending (ascending). Must be used together with `prev_created_at`."}, "limit": {"type": "integer", "maximum": 2000.0, "minimum": 0.0, "title": "Limit", "description": "Limit number of projects returned.", "default": 2000}, "ascending": {"type": "boolean", "title": "Ascending", "description": "Return projects sorted by `created_at` in ascending order. Defaults to descending.", "default": false}}, "additionalProperties": false, "type": "object", "title": "ListProjectsRequestRequest"}, "ListRunsRequestRequest": {"properties": {"project_name": {"type": "string", "title": "Project Name"}, "repo_id": {"type": "string", "title": "Repo Id"}, "username": {"type": "string", "title": "Username"}, "only_active": {"type": "boolean", "title": "Only Active", "default": false}, "include_jobs": {"type": "boolean", "title": "Include Jobs", "description": "Whether to include `jobs` in the response", "default": true}, "job_submissions_limit": {"type": "integer", "minimum": 0.0, "title": "Job Submissions Limit", "description": "Limit number of job submissions returned per job to avoid large responses.Drops older job submissions. No effect with `include_jobs: false`"}, "prev_submitted_at": {"type": "string", "format": "date-time", "title": "Prev Submitted At"}, "prev_run_id": {"type": "string", "format": "uuid", "title": "Prev Run Id"}, "limit": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Limit", "default": 100}, "ascending": {"type": "boolean", "title": "Ascending", "default": false}}, "additionalProperties": false, "type": "object", "title": "ListRunsRequestRequest"}, "ListUsersRequestRequest": {"properties": {"return_total_count": {"type": "boolean", "title": "Return Total Count", "description": "Return `total_count` with the total number of users.", "default": false}, "name_pattern": {"type": "string", "pattern": "^[a-zA-Z0-9-_]*$", "title": "Name Pattern", "description": "Include only users with the name containing `name_pattern`."}, "prev_created_at": {"type": "string", "format": "date-time", "title": "Prev Created At", "description": "Paginate users by specifying `created_at` of the last (first) user in previous batch for descending (ascending)."}, "prev_id": {"type": "string", "format": "uuid", "title": "Prev Id", "description": "Paginate users by specifying `id` of the last (first) user in previous batch for descending (ascending). Must be used together with `prev_created_at`."}, "limit": {"type": "integer", "maximum": 2000.0, "minimum": 0.0, "title": "Limit", "description": "Limit number of users returned.", "default": 2000}, "ascending": {"type": "boolean", "title": "Ascending", "description": "Return users sorted by `created_at` in ascending order. Defaults to descending.", "default": false}}, "additionalProperties": false, "type": "object", "title": "ListUsersRequestRequest"}, "ListVolumesRequestRequest": {"properties": {"project_name": {"type": "string", "title": "Project Name"}, "only_active": {"type": "boolean", "title": "Only Active", "default": false}, "prev_created_at": {"type": "string", "format": "date-time", "title": "Prev Created At"}, "prev_id": {"type": "string", "format": "uuid", "title": "Prev Id"}, "limit": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Limit", "default": 100}, "ascending": {"type": "boolean", "title": "Ascending", "default": false}}, "additionalProperties": false, "type": "object", "title": "ListVolumesRequestRequest"}, "LocalRepoInfoRequest": {"properties": {"repo_type": {"type": "string", "enum": ["local"], "title": "Repo Type", "default": "local"}, "repo_dir": {"type": "string", "title": "Repo Dir"}}, "additionalProperties": false, "type": "object", "required": ["repo_dir"], "title": "LocalRepoInfoRequest"}, "LocalRunRepoDataRequest": {"properties": {"repo_type": {"type": "string", "enum": ["local"], "title": "Repo Type", "default": "local"}, "repo_dir": {"type": "string", "title": "Repo Dir"}}, "additionalProperties": false, "type": "object", "required": ["repo_dir"], "title": "LocalRunRepoDataRequest"}, "LogEventRequest": {"properties": {"timestamp": {"type": "string", "format": "date-time", "title": "Timestamp"}, "log_source": {"$ref": "#/components/schemas/LogEventSource"}, "message": {"type": "string", "title": "Message"}}, "additionalProperties": false, "type": "object", "required": ["timestamp", "log_source", "message"], "title": "LogEventRequest"}, "LogEventSource": {"type": "string", "enum": ["stdout", "stderr"], "title": "LogEventSource", "description": "An enumeration."}, "MemberPermissionsRequest": {"properties": {"can_manage_ssh_fleets": {"type": "boolean", "title": "Can Manage Ssh Fleets"}}, "additionalProperties": false, "type": "object", "required": ["can_manage_ssh_fleets"], "title": "MemberPermissionsRequest"}, "MemberRequest": {"properties": {"user": {"$ref": "#/components/schemas/UserRequest"}, "project_role": {"$ref": "#/components/schemas/ProjectRole"}, "permissions": {"$ref": "#/components/schemas/MemberPermissionsRequest"}}, "additionalProperties": false, "type": "object", "required": ["user", "project_role", "permissions"], "title": "MemberRequest"}, "MemberSettingRequest": {"properties": {"username": {"type": "string", "title": "Username", "description": "The username or email of the user"}, "project_role": {"$ref": "#/components/schemas/ProjectRole"}}, "additionalProperties": false, "type": "object", "required": ["username", "project_role"], "title": "MemberSettingRequest"}, "MetricRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "timestamps": {"items": {"type": "string", "format": "date-time"}, "type": "array", "title": "Timestamps"}, "values": {"items": {}, "type": "array", "title": "Values"}}, "additionalProperties": false, "type": "object", "required": ["name", "timestamps", "values"], "title": "MetricRequest"}, "ModelRequest": {"properties": {"object": {"type": "string", "enum": ["model"], "title": "Object", "default": "model"}, "id": {"type": "string", "title": "Id"}, "created": {"type": "integer", "title": "Created"}, "owned_by": {"type": "string", "title": "Owned By"}}, "additionalProperties": false, "type": "object", "required": ["id", "created", "owned_by"], "title": "ModelRequest"}, "ModelsResponseRequest": {"properties": {"object": {"type": "string", "enum": ["list"], "title": "Object", "default": "list"}, "data": {"items": {"$ref": "#/components/schemas/ModelRequest"}, "type": "array", "title": "Data"}}, "additionalProperties": false, "type": "object", "required": ["data"], "title": "ModelsResponseRequest"}, "NebiusBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["nebius"], "title": "Type", "description": "The type of backend", "default": "nebius"}, "projects": {"items": {"type": "string"}, "type": "array", "title": "Projects", "description": "The list of allowed Nebius project IDs. Omit to use the default project in each region. The project is considered default if it is the only project in the region or if its name starts with `default`"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of allowed Nebius regions. Omit to allow all regions"}, "fabrics": {"items": {"type": "string"}, "type": "array", "title": "Fabrics", "description": "The list of allowed fabrics for InfiniBand clusters. Omit to allow all fabrics"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The tags (labels) that will be assigned to resources created by `dstack`"}}, "additionalProperties": false, "type": "object", "title": "NebiusBackendConfigRequest", "description": "The backend config used in the API, server/config.yml, `NebiusConfigurator`.\nIt also serves as a base class for other backend config models.\nShould not include creds."}, "NebiusBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["nebius"], "title": "Type", "description": "The type of backend", "default": "nebius"}, "projects": {"items": {"type": "string"}, "type": "array", "title": "Projects", "description": "The list of allowed Nebius project IDs. Omit to use the default project in each region. The project is considered default if it is the only project in the region or if its name starts with `default`"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of allowed Nebius regions. Omit to allow all regions"}, "fabrics": {"items": {"type": "string"}, "type": "array", "title": "Fabrics", "description": "The list of allowed fabrics for InfiniBand clusters. Omit to allow all fabrics"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The tags (labels) that will be assigned to resources created by `dstack`"}, "creds": {"allOf": [{"$ref": "#/components/schemas/NebiusServiceAccountCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "NebiusBackendConfigWithCredsRequest", "description": "Same as `NebiusBackendConfig` but also includes creds."}, "NebiusServiceAccountCredsRequest": {"properties": {"type": {"type": "string", "enum": ["service_account"], "title": "Type", "description": "The type of credentials", "default": "service_account"}, "service_account_id": {"type": "string", "title": "Service Account Id", "description": "Service account ID"}, "public_key_id": {"type": "string", "title": "Public Key Id", "description": "ID of the service account public key"}, "private_key_file": {"type": "string", "title": "Private Key File", "description": "Path to the service account private key"}, "private_key_content": {"type": "string", "title": "Private Key Content", "description": "Content of the service account private key. When configuring via `server/config.yml`, it's automatically filled from `private_key_file`. When configuring via UI, it has to be specified explicitly."}, "filename": {"type": "string", "title": "Filename", "description": "The path to the service account credentials file"}}, "additionalProperties": false, "type": "object", "required": ["service_account_id", "public_key_id", "private_key_content"], "title": "NebiusServiceAccountCredsRequest"}, "NetworkMode": {"type": "string", "enum": ["host", "bridge"], "title": "NetworkMode", "description": "An enumeration."}, "OAuthGetNextRedirectRequestRequest": {"properties": {"code": {"type": "string", "title": "Code", "description": "The OAuth2 authorization code received from the provider in the redirect URL."}, "state": {"type": "string", "title": "State", "description": "The state parameter received from the provider in the redirect URL."}}, "additionalProperties": false, "type": "object", "required": ["code", "state"], "title": "OAuthGetNextRedirectRequestRequest"}, "OAuthGetNextRedirectResponseRequest": {"properties": {"redirect_url": {"type": "string", "title": "Redirect Url", "description": "The URL that the user needs to be redirected to. If `null`, there is no next redirect."}}, "additionalProperties": false, "type": "object", "title": "OAuthGetNextRedirectResponseRequest"}, "OAuthProviderInfoRequest": {"properties": {"name": {"type": "string", "title": "Name", "description": "The OAuth2 provider name."}, "enabled": {"type": "boolean", "title": "Enabled", "description": "Whether the provider is configured on the server."}}, "additionalProperties": false, "type": "object", "required": ["name", "enabled"], "title": "OAuthProviderInfoRequest"}, "OCIBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["oci"], "title": "Type", "description": "The type of backend", "default": "oci"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of OCI regions. Omit to use all regions"}, "compartment_id": {"type": "string", "title": "Compartment Id", "description": "Compartment where `dstack` will create all resources. Omit to instruct `dstack` to create a new compartment"}}, "additionalProperties": false, "type": "object", "title": "OCIBackendConfigRequest"}, "OCIBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["oci"], "title": "Type", "description": "The type of backend", "default": "oci"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of OCI regions. Omit to use all regions"}, "compartment_id": {"type": "string", "title": "Compartment Id", "description": "Compartment where `dstack` will create all resources. Omit to instruct `dstack` to create a new compartment"}, "creds": {"oneOf": [{"$ref": "#/components/schemas/OCIClientCredsRequest"}, {"$ref": "#/components/schemas/OCIDefaultCredsRequest"}], "title": "Creds", "description": "The credentials", "discriminator": {"propertyName": "type", "mapping": {"client": "#/components/schemas/OCIClientCredsRequest", "default": "#/components/schemas/OCIDefaultCredsRequest"}}}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "OCIBackendConfigWithCredsRequest"}, "OCIClientCredsRequest": {"properties": {"type": {"type": "string", "enum": ["client"], "title": "Type", "description": "The type of credentials", "default": "client"}, "user": {"type": "string", "title": "User", "description": "User OCID"}, "tenancy": {"type": "string", "title": "Tenancy", "description": "Tenancy OCID"}, "key_file": {"type": "string", "title": "Key File", "description": "Path to the user's private PEM key. Either this or `key_content` should be set"}, "key_content": {"type": "string", "title": "Key Content", "description": "Content of the user's private PEM key. Either this or `key_file` should be set"}, "pass_phrase": {"type": "string", "title": "Pass Phrase", "description": "Passphrase for the private PEM key if it is encrypted"}, "fingerprint": {"type": "string", "title": "Fingerprint", "description": "User's public key fingerprint"}, "region": {"type": "string", "title": "Region", "description": "Name or key of any region the tenancy is subscribed to"}}, "additionalProperties": false, "type": "object", "required": ["user", "tenancy", "fingerprint", "region"], "title": "OCIClientCredsRequest"}, "OCIDefaultCredsRequest": {"properties": {"type": {"type": "string", "enum": ["default"], "title": "Type", "description": "The type of credentials", "default": "default"}, "file": {"type": "string", "title": "File", "description": "Path to the OCI CLI-compatible config file", "default": "~/.oci/config"}, "profile": {"type": "string", "title": "Profile", "description": "Profile to load from the config file", "default": "DEFAULT"}}, "additionalProperties": false, "type": "object", "title": "OCIDefaultCredsRequest"}, "OpenAIChatModelRequest": {"properties": {"type": {"type": "string", "enum": ["chat"], "title": "Type", "description": "The type of the model", "default": "chat"}, "name": {"type": "string", "title": "Name", "description": "The name of the model"}, "format": {"type": "string", "enum": ["openai"], "title": "Format", "description": "The serving format. Must be set to `openai`"}, "prefix": {"type": "string", "title": "Prefix", "description": "The `base_url` prefix (after hostname)", "default": "/v1"}}, "additionalProperties": false, "type": "object", "required": ["name", "format"], "title": "OpenAIChatModelRequest", "description": "Mapping of the model for the OpenAI-compatible endpoint.\n\nAttributes:\n type (str): The type of the model, e.g. \"chat\"\n name (str): The name of the model. This name will be used both to load model configuration from the HuggingFace Hub and in the OpenAI-compatible endpoint.\n format (str): The format of the model, i.e. \"openai\".\n prefix (str): The `base_url` prefix: `http://hostname/{prefix}/chat/completions`. Defaults to `/v1`."}, "PollLogsRequestRequest": {"properties": {"run_name": {"type": "string", "title": "Run Name"}, "job_submission_id": {"type": "string", "format": "uuid4", "title": "Job Submission Id"}, "start_time": {"type": "string", "format": "date-time", "title": "Start Time"}, "end_time": {"type": "string", "format": "date-time", "title": "End Time"}, "descending": {"type": "boolean", "title": "Descending", "default": false}, "next_token": {"type": "string", "title": "Next Token"}, "limit": {"type": "integer", "maximum": 1000.0, "minimum": 0.0, "title": "Limit", "default": 100}, "diagnose": {"type": "boolean", "title": "Diagnose", "default": false}}, "additionalProperties": false, "type": "object", "required": ["run_name", "job_submission_id"], "title": "PollLogsRequestRequest"}, "PortMappingRequest": {"properties": {"local_port": {"type": "integer", "maximum": 65536.0, "exclusiveMinimum": 0.0, "title": "Local Port"}, "container_port": {"type": "integer", "maximum": 65536.0, "exclusiveMinimum": 0.0, "title": "Container Port"}}, "additionalProperties": false, "type": "object", "required": ["container_port"], "title": "PortMappingRequest"}, "ProbeConfigRequest": {"properties": {"type": {"type": "string", "enum": ["http"], "title": "Type"}, "url": {"type": "string", "title": "Url", "description": "The URL to request. Defaults to `/`"}, "method": {"type": "string", "enum": ["get", "post", "put", "delete", "patch", "head"], "title": "Method", "description": "The HTTP method to use for the probe (e.g., `get`, `post`, etc.). Defaults to `get`"}, "headers": {"items": {"$ref": "#/components/schemas/HTTPHeaderSpecRequest"}, "type": "array", "maxItems": 16, "title": "Headers", "description": "A list of HTTP headers to include in the request", "default": []}, "body": {"type": "string", "maxLength": 2048, "minLength": 1, "title": "Body", "description": "The HTTP request body to send with the probe"}, "timeout": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Timeout", "description": "Maximum amount of time the HTTP request is allowed to take. Defaults to `10s`"}, "interval": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Interval", "description": "Minimum amount of time between the end of one probe execution and the start of the next. Defaults to `15s`"}, "ready_after": {"type": "integer", "minimum": 1.0, "title": "Ready After", "description": "The number of consecutive successful probe executions required for the replica to be considered ready. Used during rolling deployments. Defaults to `1`"}}, "additionalProperties": false, "type": "object", "required": ["type"], "title": "ProbeConfigRequest"}, "ProbeRequest": {"properties": {"success_streak": {"type": "integer", "title": "Success Streak"}}, "additionalProperties": false, "type": "object", "required": ["success_streak"], "title": "ProbeRequest"}, "ProbeSpecRequest": {"properties": {"type": {"type": "string", "enum": ["http"], "title": "Type"}, "url": {"type": "string", "title": "Url"}, "method": {"type": "string", "enum": ["get", "post", "put", "delete", "patch", "head"], "title": "Method", "default": "get"}, "headers": {"items": {"$ref": "#/components/schemas/HTTPHeaderSpecRequest"}, "type": "array", "title": "Headers", "default": []}, "body": {"type": "string", "title": "Body"}, "timeout": {"type": "integer", "title": "Timeout"}, "interval": {"type": "integer", "title": "Interval"}, "ready_after": {"type": "integer", "title": "Ready After"}}, "additionalProperties": false, "type": "object", "required": ["type", "url", "timeout", "interval", "ready_after"], "title": "ProbeSpecRequest"}, "ProfileRequest": {"properties": {"backends": {"items": {"$ref": "#/components/schemas/BackendType"}, "type": "array", "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)"}, "availability_zones": {"items": {"type": "string"}, "type": "array", "title": "Availability Zones", "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)"}, "instance_types": {"items": {"type": "string"}, "type": "array", "title": "Instance Types", "description": "The cloud-specific instance types to consider for provisioning (e.g., `[p3.8xlarge, n1-standard-4]`)"}, "reservation": {"type": "string", "title": "Reservation", "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations"}, "spot_policy": {"allOf": [{"$ref": "#/components/schemas/SpotPolicy"}], "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`"}, "retry": {"anyOf": [{"$ref": "#/components/schemas/ProfileRetryRequest"}, {"type": "boolean"}], "title": "Retry", "description": "The policy for resubmitting the run. Defaults to `false`"}, "max_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Max Duration", "description": "The maximum duration of a run (e.g., `2h`, `1d`, etc) in a running state, excluding provisioning and pulling. After it elapses, the run is automatically stopped. Use `off` for unlimited duration. Defaults to `off`"}, "stop_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Stop Duration", "description": "The maximum duration of a run graceful stopping. After it elapses, the run is automatically forced stopped. This includes force detaching volumes used by the run. Use `off` for unlimited duration. Defaults to `5m`"}, "max_price": {"type": "number", "exclusiveMinimum": 0.0, "title": "Max Price", "description": "The maximum instance price per hour, in dollars"}, "creation_policy": {"allOf": [{"$ref": "#/components/schemas/CreationPolicy"}], "description": "The policy for using instances from fleets: `reuse`, `reuse-or-create`. Defaults to `reuse-or-create`"}, "idle_duration": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Idle Duration", "description": "Time to wait before terminating idle instances. Instances are not terminated if the fleet is already at `nodes.min`. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration"}, "utilization_policy": {"allOf": [{"$ref": "#/components/schemas/UtilizationPolicyRequest"}], "title": "Utilization Policy", "description": "Run termination policy based on utilization"}, "startup_order": {"allOf": [{"$ref": "#/components/schemas/StartupOrder"}], "description": "The order in which master and workers jobs are started: `any`, `master-first`, `workers-first`. Defaults to `any`"}, "stop_criteria": {"allOf": [{"$ref": "#/components/schemas/StopCriteria"}], "description": "The criteria determining when a multi-node run should be considered finished: `all-done`, `master-done`. Defaults to `all-done`"}, "schedule": {"allOf": [{"$ref": "#/components/schemas/ScheduleRequest"}], "title": "Schedule", "description": "The schedule for starting the run at specified time"}, "fleets": {"items": {"type": "string"}, "type": "array", "title": "Fleets", "description": "The fleets considered for reuse"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them"}, "name": {"type": "string", "title": "Name", "description": "The name of the profile that can be passed as `--profile` to `dstack apply`", "default": ""}, "default": {"type": "boolean", "title": "Default", "description": "If set to true, `dstack apply` will use this profile by default.", "default": false}}, "additionalProperties": false, "type": "object", "title": "ProfileRequest"}, "ProfileRetryRequest": {"properties": {"on_events": {"items": {"$ref": "#/components/schemas/RetryEvent"}, "type": "array", "description": "The list of events that should be handled with retry. Supported events are `no-capacity`, `interruption`, `error`. Omit to retry on all events"}, "duration": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Duration", "description": "The maximum period of retrying the run, e.g., `4h` or `1d`. The period is calculated as a run age for `no-capacity` event and as a time passed since the last `interruption` and `error` for `interruption` and `error` events."}}, "additionalProperties": false, "type": "object", "title": "ProfileRetryRequest"}, "ProjectRequest": {"properties": {"project_id": {"type": "string", "format": "uuid4", "title": "Project Id"}, "project_name": {"type": "string", "title": "Project Name"}, "owner": {"$ref": "#/components/schemas/UserRequest"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "backends": {"items": {"$ref": "#/components/schemas/BackendInfoRequest"}, "type": "array", "title": "Backends"}, "members": {"items": {"$ref": "#/components/schemas/MemberRequest"}, "type": "array", "title": "Members"}, "is_public": {"type": "boolean", "title": "Is Public", "default": false}}, "additionalProperties": false, "type": "object", "required": ["project_id", "project_name", "owner", "backends", "members"], "title": "ProjectRequest"}, "ProjectRole": {"type": "string", "enum": ["admin", "manager", "user"], "title": "ProjectRole", "description": "An enumeration."}, "ProjectsInfoListRequest": {"properties": {"total_count": {"type": "integer", "title": "Total Count"}, "projects": {"items": {"$ref": "#/components/schemas/ProjectRequest"}, "type": "array", "title": "Projects"}}, "additionalProperties": false, "type": "object", "required": ["projects"], "title": "ProjectsInfoListRequest"}, "PythonVersion": {"type": "string", "enum": ["3.9", "3.10", "3.11", "3.12", "3.13"], "title": "PythonVersion", "description": "An enumeration."}, "Range_Memory_": {"properties": {"min": {"type": "number", "title": "Min"}, "max": {"type": "number", "title": "Max"}}, "additionalProperties": false, "type": "object", "title": "Range[Memory]"}, "Range_float_": {"properties": {"min": {"type": "number", "title": "Min"}, "max": {"type": "number", "title": "Max"}}, "additionalProperties": false, "type": "object", "title": "Range[float]"}, "Range_int_": {"properties": {"min": {"type": "integer", "title": "Min"}, "max": {"type": "integer", "title": "Max"}}, "additionalProperties": false, "type": "object", "title": "Range[int]"}, "RateLimitRequest": {"properties": {"prefix": {"type": "string", "maxLength": 4094, "pattern": "^/[^\\s\\\\{}]*$", "title": "Prefix", "description": "URL path prefix to which this limit is applied. If an incoming request matches several prefixes, the longest prefix is applied", "default": "/"}, "key": {"oneOf": [{"$ref": "#/components/schemas/IPAddressPartitioningKeyRequest"}, {"$ref": "#/components/schemas/HeaderPartitioningKeyRequest"}], "title": "Key", "description": "The partitioning key. Each incoming request belongs to a partition and rate limits are applied per partition. Defaults to partitioning by client IP address", "default": {"type": "ip_address"}, "discriminator": {"propertyName": "type", "mapping": {"ip_address": "#/components/schemas/IPAddressPartitioningKeyRequest", "header": "#/components/schemas/HeaderPartitioningKeyRequest"}}}, "rps": {"type": "number", "maximum": 1.5372286728091293e+17, "minimum": 0.016666666666666666, "title": "Rps", "description": "Max allowed number of requests per second. Requests are tracked at millisecond granularity. For example, `rps: 10` means at most 1 request per 100ms"}, "burst": {"type": "integer", "maximum": 9.223372036854776e+18, "minimum": 0.0, "title": "Burst", "description": "Max number of requests that can be passed to the service ahead of the rate limit", "default": 0}}, "additionalProperties": false, "type": "object", "required": ["rps"], "title": "RateLimitRequest"}, "RefreshTokenRequestRequest": {"properties": {"username": {"type": "string", "title": "Username"}}, "additionalProperties": false, "type": "object", "required": ["username"], "title": "RefreshTokenRequestRequest"}, "RegistryAuthRequest": {"properties": {"username": {"type": "string", "title": "Username", "description": "The username"}, "password": {"type": "string", "title": "Password", "description": "The password or access token"}}, "additionalProperties": false, "type": "object", "required": ["username", "password"], "title": "RegistryAuthRequest", "description": "Credentials for pulling a private Docker image.\n\nAttributes:\n username (str): The username\n password (str): The password or access token"}, "RemoteRepoCredsRequest": {"properties": {"clone_url": {"type": "string", "title": "Clone Url"}, "private_key": {"type": "string", "title": "Private Key"}, "oauth_token": {"type": "string", "title": "Oauth Token"}}, "additionalProperties": false, "type": "object", "required": ["clone_url"], "title": "RemoteRepoCredsRequest"}, "RemoteRepoInfoRequest": {"properties": {"repo_type": {"type": "string", "enum": ["remote"], "title": "Repo Type", "default": "remote"}, "repo_name": {"type": "string", "title": "Repo Name"}}, "additionalProperties": false, "type": "object", "required": ["repo_name"], "title": "RemoteRepoInfoRequest"}, "RemoteRunRepoDataRequest": {"properties": {"repo_type": {"type": "string", "enum": ["remote"], "title": "Repo Type", "default": "remote"}, "repo_name": {"type": "string", "title": "Repo Name"}, "repo_branch": {"type": "string", "title": "Repo Branch"}, "repo_hash": {"type": "string", "title": "Repo Hash"}, "repo_diff": {"type": "string", "title": "Repo Diff"}, "repo_config_name": {"type": "string", "title": "Repo Config Name"}, "repo_config_email": {"type": "string", "title": "Repo Config Email"}}, "additionalProperties": false, "type": "object", "required": ["repo_name"], "title": "RemoteRunRepoDataRequest"}, "RemoveProjectMemberRequestRequest": {"properties": {"usernames": {"items": {"type": "string"}, "type": "array", "title": "Usernames"}}, "additionalProperties": false, "type": "object", "required": ["usernames"], "title": "RemoveProjectMemberRequestRequest"}, "ReplicaGroupRequest": {"properties": {"name": {"type": "string", "title": "Name", "description": "The name of the replica group. If not provided, defaults to '0', '1', etc. based on position."}, "count": {"allOf": [{"$ref": "#/components/schemas/Range_int_"}], "title": "Count", "description": "The number of replicas. Can be a number (e.g. `2`) or a range (`0..4` or `1..8`). If it's a range, the `scaling` property is required"}, "scaling": {"allOf": [{"$ref": "#/components/schemas/ScalingSpecRequest"}], "title": "Scaling", "description": "The auto-scaling rules. Required if `count` is set to a range"}, "resources": {"allOf": [{"$ref": "#/components/schemas/ResourcesSpecRequest"}], "title": "Resources", "description": "The resources requirements for replicas in this group", "default": {"cpu": {"min": 2}, "memory": {"min": 8.0}, "disk": {"size": {"min": 100.0}}}}, "commands": {"items": {"type": "string"}, "type": "array", "title": "Commands", "description": "The shell commands to run for replicas in this group", "default": []}}, "additionalProperties": false, "type": "object", "required": ["count"], "title": "ReplicaGroupRequest"}, "RepoExistsAction": {"type": "string", "enum": ["error", "skip"], "title": "RepoExistsAction", "description": "An enumeration."}, "RepoHeadRequest": {"properties": {"repo_id": {"type": "string", "title": "Repo Id"}, "repo_info": {"oneOf": [{"$ref": "#/components/schemas/RemoteRepoInfoRequest"}, {"$ref": "#/components/schemas/LocalRepoInfoRequest"}, {"$ref": "#/components/schemas/VirtualRepoInfoRequest"}], "title": "Repo Info", "discriminator": {"propertyName": "repo_type", "mapping": {"remote": "#/components/schemas/RemoteRepoInfoRequest", "local": "#/components/schemas/LocalRepoInfoRequest", "virtual": "#/components/schemas/VirtualRepoInfoRequest"}}}}, "additionalProperties": false, "type": "object", "required": ["repo_id", "repo_info"], "title": "RepoHeadRequest"}, "RepoHeadWithCredsRequest": {"properties": {"repo_id": {"type": "string", "title": "Repo Id"}, "repo_info": {"oneOf": [{"$ref": "#/components/schemas/RemoteRepoInfoRequest"}, {"$ref": "#/components/schemas/LocalRepoInfoRequest"}, {"$ref": "#/components/schemas/VirtualRepoInfoRequest"}], "title": "Repo Info", "discriminator": {"propertyName": "repo_type", "mapping": {"remote": "#/components/schemas/RemoteRepoInfoRequest", "local": "#/components/schemas/LocalRepoInfoRequest", "virtual": "#/components/schemas/VirtualRepoInfoRequest"}}}, "repo_creds": {"$ref": "#/components/schemas/RemoteRepoCredsRequest"}}, "additionalProperties": false, "type": "object", "required": ["repo_id", "repo_info"], "title": "RepoHeadWithCredsRequest"}, "RepoSpecRequest": {"properties": {"local_path": {"type": "string", "title": "Local Path", "description": "The path to the Git repo on the user's machine. Relative paths are resolved relative to the parent directory of the the configuration file. Mutually exclusive with `url`"}, "url": {"type": "string", "title": "Url", "description": "The Git repo URL. Mutually exclusive with `local_path`"}, "branch": {"type": "string", "title": "Branch", "description": "The repo branch. Defaults to the active branch for local paths and the default branch for URLs"}, "hash": {"type": "string", "title": "Hash", "description": "The commit hash"}, "path": {"type": "string", "title": "Path", "description": "The repo path inside the run container. Relative paths are resolved relative to the working directory", "default": "."}, "if_exists": {"allOf": [{"$ref": "#/components/schemas/RepoExistsAction"}], "description": "The action to be taken if `path` exists and is not empty. One of: `error`, `skip`", "default": "error"}}, "additionalProperties": false, "type": "object", "title": "RepoSpecRequest"}, "RequirementsRequest": {"properties": {"resources": {"$ref": "#/components/schemas/ResourcesSpecRequest"}, "max_price": {"type": "number", "title": "Max Price"}, "spot": {"type": "boolean", "title": "Spot"}, "reservation": {"type": "string", "title": "Reservation"}, "multinode": {"type": "boolean", "title": "Multinode"}}, "additionalProperties": false, "type": "object", "required": ["resources"], "title": "RequirementsRequest"}, "ResourcesRequest": {"properties": {"cpus": {"type": "integer", "title": "Cpus"}, "memory_mib": {"type": "integer", "title": "Memory Mib"}, "gpus": {"items": {"$ref": "#/components/schemas/GpuRequest"}, "type": "array", "title": "Gpus"}, "spot": {"type": "boolean", "title": "Spot"}, "disk": {"allOf": [{"$ref": "#/components/schemas/DiskRequest"}], "title": "Disk", "default": {"size_mib": 102400}}, "cpu_arch": {"$ref": "#/components/schemas/CPUArchitecture"}, "description": {"type": "string", "title": "Description", "default": ""}}, "additionalProperties": false, "type": "object", "required": ["cpus", "memory_mib", "gpus", "spot"], "title": "ResourcesRequest"}, "ResourcesSpecRequest": {"properties": {"cpu": {"anyOf": [{"$ref": "#/components/schemas/CPUSpecRequest"}, {"$ref": "#/components/schemas/Range_int_"}, {"type": "integer"}, {"type": "string"}], "title": "Cpu", "description": "The CPU requirements", "default": {"count": {"min": 2}}}, "memory": {"anyOf": [{"$ref": "#/components/schemas/Range_Memory_"}, {"type": "integer"}, {"type": "string"}], "title": "Memory", "description": "The RAM size (e.g., `8GB`)", "default": {"min": 8.0}}, "shm_size": {"anyOf": [{"type": "number"}, {"type": "integer"}, {"type": "string"}], "title": "Shm Size", "description": "The size of shared memory (e.g., `8GB`). If you are using parallel communicating processes (e.g., dataloaders in PyTorch), you may need to configure this"}, "gpu": {"anyOf": [{"$ref": "#/components/schemas/GPUSpecRequest"}, {"type": "integer"}, {"type": "string"}], "title": "Gpu", "description": "The GPU requirements"}, "disk": {"anyOf": [{"$ref": "#/components/schemas/DiskSpecRequest"}, {"type": "integer"}, {"type": "string"}], "title": "Disk", "description": "The disk resources", "default": {"size": {"min": 100.0}}}}, "additionalProperties": false, "type": "object", "title": "ResourcesSpecRequest"}, "RetryEvent": {"type": "string", "enum": ["no-capacity", "interruption", "error"], "title": "RetryEvent", "description": "An enumeration."}, "RetryRequest": {"properties": {"on_events": {"items": {"$ref": "#/components/schemas/RetryEvent"}, "type": "array"}, "duration": {"type": "integer", "title": "Duration"}}, "additionalProperties": false, "type": "object", "required": ["on_events", "duration"], "title": "RetryRequest"}, "RunFleetRequest": {"properties": {"id": {"type": "string", "format": "uuid4", "title": "Id"}, "name": {"type": "string", "title": "Name"}}, "additionalProperties": false, "type": "object", "required": ["id", "name"], "title": "RunFleetRequest"}, "RunPlanRequest": {"properties": {"project_name": {"type": "string", "title": "Project Name"}, "user": {"type": "string", "title": "User"}, "run_spec": {"$ref": "#/components/schemas/RunSpecRequest"}, "effective_run_spec": {"$ref": "#/components/schemas/RunSpecRequest"}, "job_plans": {"items": {"$ref": "#/components/schemas/JobPlanRequest"}, "type": "array", "title": "Job Plans"}, "current_resource": {"$ref": "#/components/schemas/RunRequest"}, "action": {"$ref": "#/components/schemas/ApplyAction"}}, "additionalProperties": false, "type": "object", "required": ["project_name", "user", "run_spec", "job_plans", "action"], "title": "RunPlanRequest"}, "RunRequest": {"properties": {"id": {"type": "string", "format": "uuid4", "title": "Id"}, "project_name": {"type": "string", "title": "Project Name"}, "user": {"type": "string", "title": "User"}, "fleet": {"$ref": "#/components/schemas/RunFleetRequest"}, "submitted_at": {"type": "string", "format": "date-time", "title": "Submitted At"}, "last_processed_at": {"type": "string", "format": "date-time", "title": "Last Processed At"}, "status": {"$ref": "#/components/schemas/RunStatus"}, "status_message": {"type": "string", "title": "Status Message", "default": ""}, "termination_reason": {"type": "string", "title": "Termination Reason"}, "run_spec": {"$ref": "#/components/schemas/RunSpecRequest"}, "jobs": {"items": {"$ref": "#/components/schemas/JobRequest"}, "type": "array", "title": "Jobs"}, "latest_job_submission": {"$ref": "#/components/schemas/JobSubmissionRequest"}, "cost": {"type": "number", "title": "Cost", "default": 0}, "service": {"$ref": "#/components/schemas/ServiceSpecRequest"}, "deployment_num": {"type": "integer", "title": "Deployment Num", "default": 0}, "error": {"type": "string", "title": "Error"}, "deleted": {"type": "boolean", "title": "Deleted"}, "next_triggered_at": {"type": "string", "format": "date-time", "title": "Next Triggered At"}}, "additionalProperties": false, "type": "object", "required": ["id", "project_name", "user", "submitted_at", "last_processed_at", "status", "run_spec", "jobs"], "title": "RunRequest"}, "RunSpecRequest": {"properties": {"run_name": {"type": "string", "title": "Run Name", "description": "The run name. If not set, the run name is generated automatically."}, "repo_id": {"type": "string", "title": "Repo Id", "description": "Same `repo_id` that is specified when initializing the repo by calling the `/api/project/{project_name}/repos/init` endpoint. If not specified, a default virtual repo is used."}, "repo_data": {"oneOf": [{"$ref": "#/components/schemas/RemoteRunRepoDataRequest"}, {"$ref": "#/components/schemas/LocalRunRepoDataRequest"}, {"$ref": "#/components/schemas/VirtualRunRepoDataRequest"}], "title": "Repo Data", "description": "The repo data such as the current branch and commit.", "discriminator": {"propertyName": "repo_type", "mapping": {"remote": "#/components/schemas/RemoteRunRepoDataRequest", "local": "#/components/schemas/LocalRunRepoDataRequest", "virtual": "#/components/schemas/VirtualRunRepoDataRequest"}}}, "repo_code_hash": {"type": "string", "title": "Repo Code Hash", "description": "The hash of the repo diff. Can be omitted if there is no repo diff."}, "repo_dir": {"type": "string", "title": "Repo Dir", "description": "The repo path inside the container. Relative paths are resolved relative to the working directory."}, "file_archives": {"items": {"$ref": "#/components/schemas/FileArchiveMappingRequest"}, "type": "array", "title": "File Archives", "description": "The list of file archive ID to container path mappings.", "default": []}, "working_dir": {"type": "string", "title": "Working Dir"}, "configuration_path": {"type": "string", "title": "Configuration Path", "description": "The path to the run configuration YAML file. It can be omitted when using the programmatic API."}, "configuration": {"oneOf": [{"$ref": "#/components/schemas/DevEnvironmentConfigurationRequest"}, {"$ref": "#/components/schemas/TaskConfigurationRequest"}, {"$ref": "#/components/schemas/ServiceConfigurationRequest"}], "title": "Configuration", "discriminator": {"propertyName": "type", "mapping": {"dev-environment": "#/components/schemas/DevEnvironmentConfigurationRequest", "task": "#/components/schemas/TaskConfigurationRequest", "service": "#/components/schemas/ServiceConfigurationRequest"}}}, "profile": {"allOf": [{"$ref": "#/components/schemas/ProfileRequest"}], "title": "Profile", "description": "The profile parameters"}, "ssh_key_pub": {"type": "string", "title": "Ssh Key Pub", "description": "The contents of the SSH public key that will be used to connect to the run. Can be empty only before the run is submitted."}}, "additionalProperties": false, "type": "object", "required": ["configuration"], "title": "RunSpecRequest"}, "RunStatus": {"type": "string", "enum": ["pending", "submitted", "provisioning", "running", "terminating", "terminated", "failed", "done"], "title": "RunStatus", "description": "An enumeration."}, "RunpodAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The API key"}}, "additionalProperties": false, "type": "object", "required": ["api_key"], "title": "RunpodAPIKeyCredsRequest"}, "RunpodBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["runpod"], "title": "Type", "default": "runpod"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of RunPod regions. Omit to use all regions"}, "community_cloud": {"type": "boolean", "title": "Community Cloud", "description": "Whether Community Cloud offers can be suggested in addition to Secure Cloud. Defaults to `true`"}}, "additionalProperties": false, "type": "object", "title": "RunpodBackendConfigRequest"}, "RunpodBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["runpod"], "title": "Type", "default": "runpod"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of RunPod regions. Omit to use all regions"}, "community_cloud": {"type": "boolean", "title": "Community Cloud", "description": "Whether Community Cloud offers can be suggested in addition to Secure Cloud. Defaults to `true`"}, "creds": {"allOf": [{"$ref": "#/components/schemas/RunpodAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "RunpodBackendConfigWithCredsRequest"}, "SGLangRouterConfigRequest": {"properties": {"type": {"type": "string", "enum": ["sglang"], "title": "Type", "description": "The router type", "default": "sglang"}, "policy": {"type": "string", "enum": ["random", "round_robin", "cache_aware", "power_of_two"], "title": "Policy", "description": "The routing policy. Options: `random`, `round_robin`, `cache_aware`, `power_of_two`", "default": "cache_aware"}}, "additionalProperties": false, "type": "object", "title": "SGLangRouterConfigRequest"}, "SSHConnectionParamsRequest": {"properties": {"hostname": {"type": "string", "title": "Hostname"}, "username": {"type": "string", "title": "Username"}, "port": {"type": "integer", "title": "Port"}}, "additionalProperties": false, "type": "object", "required": ["hostname", "username", "port"], "title": "SSHConnectionParamsRequest"}, "SSHHostParamsRequest": {"properties": {"hostname": {"type": "string", "title": "Hostname", "description": "The IP address or domain to connect to"}, "port": {"type": "integer", "title": "Port", "description": "The SSH port to connect to for this host"}, "user": {"type": "string", "title": "User", "description": "The user to log in with for this host"}, "identity_file": {"type": "string", "title": "Identity File", "description": "The private key to use for this host"}, "proxy_jump": {"allOf": [{"$ref": "#/components/schemas/SSHProxyParamsRequest"}], "title": "Proxy Jump", "description": "The SSH proxy configuration for this host"}, "internal_ip": {"type": "string", "title": "Internal Ip", "description": "The internal IP of the host used for communication inside the cluster. If not specified, `dstack` will use the IP address from `network` or from the first found internal network."}, "ssh_key": {"$ref": "#/components/schemas/SSHKeyRequest"}, "blocks": {"anyOf": [{"type": "string", "enum": ["auto"]}, {"type": "integer", "minimum": 1.0}], "title": "Blocks", "description": "The amount of blocks to split the instance into, a number or `auto`. `auto` means as many as possible. The number of GPUs and CPUs must be divisible by the number of blocks. Defaults to `1`, i.e. do not split", "default": 1}}, "additionalProperties": false, "type": "object", "required": ["hostname"], "title": "SSHHostParamsRequest"}, "SSHKeyRequest": {"properties": {"public": {"type": "string", "title": "Public"}, "private": {"type": "string", "title": "Private"}}, "additionalProperties": false, "type": "object", "required": ["public"], "title": "SSHKeyRequest"}, "SSHParamsRequest": {"properties": {"user": {"type": "string", "title": "User", "description": "The user to log in with on all hosts"}, "port": {"type": "integer", "title": "Port", "description": "The SSH port to connect to"}, "identity_file": {"type": "string", "title": "Identity File", "description": "The private key to use for all hosts"}, "ssh_key": {"$ref": "#/components/schemas/SSHKeyRequest"}, "proxy_jump": {"allOf": [{"$ref": "#/components/schemas/SSHProxyParamsRequest"}], "title": "Proxy Jump", "description": "The SSH proxy configuration for all hosts"}, "hosts": {"items": {"anyOf": [{"$ref": "#/components/schemas/SSHHostParamsRequest"}, {"type": "string"}]}, "type": "array", "title": "Hosts", "description": "The per host connection parameters: a hostname or an object that overrides default ssh parameters"}, "network": {"type": "string", "title": "Network", "description": "The network address for cluster setup in the format `/`. `dstack` will use IP addresses from this network for communication between hosts. If not specified, `dstack` will use IPs from the first found internal network."}}, "additionalProperties": false, "type": "object", "required": ["hosts"], "title": "SSHParamsRequest"}, "SSHProxyParamsRequest": {"properties": {"hostname": {"type": "string", "title": "Hostname", "description": "The IP address or domain of proxy host"}, "port": {"type": "integer", "title": "Port", "description": "The SSH port of proxy host"}, "user": {"type": "string", "title": "User", "description": "The user to log in with for proxy host"}, "identity_file": {"type": "string", "title": "Identity File", "description": "The private key to use for proxy host"}, "ssh_key": {"$ref": "#/components/schemas/SSHKeyRequest"}}, "additionalProperties": false, "type": "object", "required": ["hostname", "user", "identity_file"], "title": "SSHProxyParamsRequest"}, "SaveRepoCredsRequestRequest": {"properties": {"repo_id": {"type": "string", "title": "Repo Id", "description": "A unique identifier of the repo"}, "repo_info": {"anyOf": [{"$ref": "#/components/schemas/RemoteRepoInfoRequest"}, {"$ref": "#/components/schemas/LocalRepoInfoRequest"}, {"$ref": "#/components/schemas/VirtualRepoInfoRequest"}], "title": "Repo Info"}, "repo_creds": {"allOf": [{"$ref": "#/components/schemas/RemoteRepoCredsRequest"}], "title": "Repo Creds", "description": "The repo creds for accessing private remote repo"}}, "additionalProperties": false, "type": "object", "required": ["repo_id", "repo_info"], "title": "SaveRepoCredsRequestRequest"}, "ScalingSpecRequest": {"properties": {"metric": {"type": "string", "enum": ["rps"], "title": "Metric", "description": "The target metric to track. Currently, the only supported value is `rps` (meaning requests per second)"}, "target": {"type": "number", "exclusiveMinimum": 0.0, "title": "Target", "description": "The target value of the metric. The number of replicas is calculated based on this number and automatically adjusts (scales up or down) as this metric changes"}, "scale_up_delay": {"type": "integer", "title": "Scale Up Delay", "description": "The delay in seconds before scaling up", "default": 300}, "scale_down_delay": {"type": "integer", "title": "Scale Down Delay", "description": "The delay in seconds before scaling down", "default": 600}}, "additionalProperties": false, "type": "object", "required": ["metric", "target"], "title": "ScalingSpecRequest"}, "ScheduleRequest": {"properties": {"cron": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "string"}], "title": "Cron", "description": "A cron expression or a list of cron expressions specifying the UTC time when the run needs to be started"}}, "additionalProperties": false, "type": "object", "required": ["cron"], "title": "ScheduleRequest"}, "SecretRequest": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "name": {"type": "string", "title": "Name"}, "value": {"type": "string", "title": "Value"}}, "additionalProperties": false, "type": "object", "required": ["id", "name"], "title": "SecretRequest"}, "ServerClientErrorCode": {"type": "string", "enum": ["error", "resource_exists", "resource_not_exists", "invalid_credentials", "backend_not_available", "repo_does_not_exist", "gateway_error"], "title": "ServerClientErrorCode", "description": "An enumeration."}, "ServerInfoRequest": {"properties": {"server_version": {"type": "string", "title": "Server Version"}}, "additionalProperties": false, "type": "object", "title": "ServerInfoRequest"}, "ServiceConfigurationRequest": {"properties": {"port": {"anyOf": [{"type": "integer", "maximum": 65536.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^[0-9]+:[0-9]+$"}, {"$ref": "#/components/schemas/PortMappingRequest"}], "title": "Port", "description": "The port the application listens on"}, "gateway": {"anyOf": [{"type": "boolean"}, {"type": "string"}], "title": "Gateway", "description": "The name of the gateway. Specify boolean `false` to run without a gateway. Specify boolean `true` to run with the default gateway. Omit to run with the default gateway if there is one, or without a gateway otherwise"}, "strip_prefix": {"type": "boolean", "title": "Strip Prefix", "description": "Strip the `/proxy/services///` path prefix when forwarding requests to the service. Only takes effect when running the service without a gateway", "default": true}, "model": {"anyOf": [{"$ref": "#/components/schemas/TGIChatModelRequest"}, {"$ref": "#/components/schemas/OpenAIChatModelRequest"}, {"type": "string"}], "title": "Model", "description": "Mapping of the model for the OpenAI-compatible endpoint provided by `dstack`. Can be a full model format definition or just a model name. If it's a name, the service is expected to expose an OpenAI-compatible API at the `/v1` path"}, "https": {"type": "boolean", "title": "Https", "description": "Enable HTTPS if running with a gateway", "default": true}, "auth": {"type": "boolean", "title": "Auth", "description": "Enable the authorization", "default": true}, "scaling": {"allOf": [{"$ref": "#/components/schemas/ScalingSpecRequest"}], "title": "Scaling", "description": "The auto-scaling rules. Required if `replicas` is set to a range"}, "rate_limits": {"items": {"$ref": "#/components/schemas/RateLimitRequest"}, "type": "array", "title": "Rate Limits", "description": "Rate limiting rules", "default": []}, "probes": {"items": {"$ref": "#/components/schemas/ProbeConfigRequest"}, "type": "array", "title": "Probes", "description": "List of probes used to determine job health", "default": []}, "replicas": {"anyOf": [{"items": {"$ref": "#/components/schemas/ReplicaGroupRequest"}, "type": "array"}, {"$ref": "#/components/schemas/Range_int_"}, {"type": "integer"}, {"type": "string"}], "title": "Replicas", "description": "The number of replicas or a list of replica groups. Can be an integer (e.g., `2`), a range (e.g., `0..4`), or a list of replica groups. Each replica group defines replicas with shared configuration (commands, resources, scaling). When `replicas` is a list of replica groups, top-level `scaling`, `commands`, and `resources` are not allowed and must be specified in each replica group instead. "}, "commands": {"items": {"type": "string"}, "type": "array", "title": "Commands", "description": "The shell commands to run", "default": []}, "type": {"type": "string", "enum": ["service"], "title": "Type", "default": "service"}, "name": {"type": "string", "title": "Name", "description": "The run name. If not specified, a random name is generated"}, "image": {"type": "string", "title": "Image", "description": "The name of the Docker image to run"}, "user": {"type": "string", "title": "User", "description": "The user inside the container, `user_name_or_id[:group_name_or_id]` (e.g., `ubuntu`, `1000:1000`). Defaults to the default user from the `image`"}, "privileged": {"type": "boolean", "title": "Privileged", "description": "Run the container in privileged mode", "default": false}, "entrypoint": {"type": "string", "title": "Entrypoint", "description": "The Docker entrypoint"}, "working_dir": {"type": "string", "title": "Working Dir", "description": "The absolute path to the working directory inside the container. Defaults to the `image`'s default working directory"}, "home_dir": {"type": "string", "title": "Home Dir", "default": "/root"}, "registry_auth": {"allOf": [{"$ref": "#/components/schemas/RegistryAuthRequest"}], "title": "Registry Auth", "description": "Credentials for pulling a private Docker image"}, "python": {"allOf": [{"$ref": "#/components/schemas/PythonVersion"}], "description": "The major version of Python. Mutually exclusive with `image` and `docker`"}, "nvcc": {"type": "boolean", "title": "Nvcc", "description": "Use image with NVIDIA CUDA Compiler (NVCC) included. Mutually exclusive with `image` and `docker`"}, "single_branch": {"type": "boolean", "title": "Single Branch", "description": "Whether to clone and track only the current branch or all remote branches. Relevant only when using remote Git repos. Defaults to `false` for dev environments and to `true` for tasks and services"}, "env": {"allOf": [{"$ref": "#/components/schemas/Env"}], "title": "Env", "description": "The mapping or the list of environment variables", "default": {"__root__": {}}}, "shell": {"type": "string", "title": "Shell", "description": "The shell used to run commands. Allowed values are `sh`, `bash`, or an absolute path, e.g., `/usr/bin/zsh`. Defaults to `/bin/sh` if the `image` is specified, `/bin/bash` otherwise"}, "resources": {"allOf": [{"$ref": "#/components/schemas/ResourcesSpecRequest"}], "title": "Resources", "description": "The resources requirements to run the configuration", "default": {"cpu": {"min": 2}, "memory": {"min": 8.0}, "disk": {"size": {"min": 100.0}}}}, "priority": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Priority", "description": "The priority of the run, an integer between `0` and `100`. `dstack` tries to provision runs with higher priority first. Defaults to `0`"}, "volumes": {"items": {"anyOf": [{"$ref": "#/components/schemas/VolumeMountPointRequest"}, {"$ref": "#/components/schemas/InstanceMountPointRequest"}, {"type": "string"}]}, "type": "array", "title": "Volumes", "description": "The volumes mount points", "default": []}, "docker": {"type": "boolean", "title": "Docker", "description": "Use Docker inside the container. Mutually exclusive with `image`, `python`, and `nvcc`. Overrides `privileged`"}, "repos": {"items": {"$ref": "#/components/schemas/RepoSpecRequest"}, "type": "array", "title": "Repos", "description": "The list of Git repos", "default": []}, "files": {"items": {"anyOf": [{"$ref": "#/components/schemas/FilePathMappingRequest"}, {"type": "string"}]}, "type": "array", "title": "Files", "description": "The local to container file path mappings", "default": []}, "setup": {"items": {"type": "string"}, "type": "array", "title": "Setup", "default": []}, "backends": {"items": {"$ref": "#/components/schemas/BackendType"}, "type": "array", "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)"}, "availability_zones": {"items": {"type": "string"}, "type": "array", "title": "Availability Zones", "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)"}, "instance_types": {"items": {"type": "string"}, "type": "array", "title": "Instance Types", "description": "The cloud-specific instance types to consider for provisioning (e.g., `[p3.8xlarge, n1-standard-4]`)"}, "reservation": {"type": "string", "title": "Reservation", "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations"}, "spot_policy": {"allOf": [{"$ref": "#/components/schemas/SpotPolicy"}], "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`"}, "retry": {"anyOf": [{"$ref": "#/components/schemas/ProfileRetryRequest"}, {"type": "boolean"}], "title": "Retry", "description": "The policy for resubmitting the run. Defaults to `false`"}, "max_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Max Duration", "description": "The maximum duration of a run (e.g., `2h`, `1d`, etc) in a running state, excluding provisioning and pulling. After it elapses, the run is automatically stopped. Use `off` for unlimited duration. Defaults to `off`"}, "stop_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Stop Duration", "description": "The maximum duration of a run graceful stopping. After it elapses, the run is automatically forced stopped. This includes force detaching volumes used by the run. Use `off` for unlimited duration. Defaults to `5m`"}, "max_price": {"type": "number", "exclusiveMinimum": 0.0, "title": "Max Price", "description": "The maximum instance price per hour, in dollars"}, "creation_policy": {"allOf": [{"$ref": "#/components/schemas/CreationPolicy"}], "description": "The policy for using instances from fleets: `reuse`, `reuse-or-create`. Defaults to `reuse-or-create`"}, "idle_duration": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Idle Duration", "description": "Time to wait before terminating idle instances. Instances are not terminated if the fleet is already at `nodes.min`. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration"}, "utilization_policy": {"allOf": [{"$ref": "#/components/schemas/UtilizationPolicyRequest"}], "title": "Utilization Policy", "description": "Run termination policy based on utilization"}, "startup_order": {"allOf": [{"$ref": "#/components/schemas/StartupOrder"}], "description": "The order in which master and workers jobs are started: `any`, `master-first`, `workers-first`. Defaults to `any`"}, "stop_criteria": {"allOf": [{"$ref": "#/components/schemas/StopCriteria"}], "description": "The criteria determining when a multi-node run should be considered finished: `all-done`, `master-done`. Defaults to `all-done`"}, "schedule": {"allOf": [{"$ref": "#/components/schemas/ScheduleRequest"}], "title": "Schedule", "description": "The schedule for starting the run at specified time"}, "fleets": {"items": {"type": "string"}, "type": "array", "title": "Fleets", "description": "The fleets considered for reuse"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them"}}, "additionalProperties": false, "type": "object", "required": ["port"], "title": "ServiceConfigurationRequest"}, "ServiceModelSpecRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "base_url": {"type": "string", "title": "Base Url", "description": "Full URL or path relative to dstack-server's base URL"}, "type": {"type": "string", "title": "Type"}}, "additionalProperties": false, "type": "object", "required": ["name", "base_url", "type"], "title": "ServiceModelSpecRequest"}, "ServiceSpecRequest": {"properties": {"url": {"type": "string", "title": "Url", "description": "Full URL or path relative to dstack-server's base URL"}, "model": {"$ref": "#/components/schemas/ServiceModelSpecRequest"}, "options": {"type": "object", "title": "Options", "default": {}}}, "additionalProperties": false, "type": "object", "required": ["url"], "title": "ServiceSpecRequest"}, "SetDefaultGatewayRequestRequest": {"properties": {"name": {"type": "string", "title": "Name"}}, "additionalProperties": false, "type": "object", "required": ["name"], "title": "SetDefaultGatewayRequestRequest"}, "SetProjectMembersRequestRequest": {"properties": {"members": {"items": {"$ref": "#/components/schemas/MemberSettingRequest"}, "type": "array", "title": "Members"}}, "additionalProperties": false, "type": "object", "required": ["members"], "title": "SetProjectMembersRequestRequest"}, "SetWildcardDomainRequestRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "wildcard_domain": {"type": "string", "title": "Wildcard Domain"}}, "additionalProperties": false, "type": "object", "required": ["name", "wildcard_domain"], "title": "SetWildcardDomainRequestRequest"}, "SpotPolicy": {"type": "string", "enum": ["spot", "on-demand", "auto"], "title": "SpotPolicy", "description": "An enumeration."}, "StartupOrder": {"type": "string", "enum": ["any", "master-first", "workers-first"], "title": "StartupOrder", "description": "An enumeration."}, "StopCriteria": {"type": "string", "enum": ["all-done", "master-done"], "title": "StopCriteria", "description": "An enumeration."}, "StopRunsRequestRequest": {"properties": {"runs_names": {"items": {"type": "string"}, "type": "array", "title": "Runs Names"}, "abort": {"type": "boolean", "title": "Abort", "description": "Do not wait for a graceful shutdown."}}, "additionalProperties": false, "type": "object", "required": ["runs_names", "abort"], "title": "StopRunsRequestRequest"}, "SubmitRunRequestRequest": {"properties": {"run_spec": {"$ref": "#/components/schemas/RunSpecRequest"}}, "additionalProperties": false, "type": "object", "required": ["run_spec"], "title": "SubmitRunRequestRequest"}, "TGIChatModelRequest": {"properties": {"type": {"type": "string", "enum": ["chat"], "title": "Type", "description": "The type of the model", "default": "chat"}, "name": {"type": "string", "title": "Name", "description": "The name of the model"}, "format": {"type": "string", "enum": ["tgi"], "title": "Format", "description": "The serving format. Must be set to `tgi`"}, "chat_template": {"type": "string", "title": "Chat Template", "description": "The custom prompt template for the model. If not specified, the default prompt template from the HuggingFace Hub configuration will be used"}, "eos_token": {"type": "string", "title": "Eos Token", "description": "The custom end of sentence token. If not specified, the default end of sentence token from the HuggingFace Hub configuration will be used"}}, "additionalProperties": false, "type": "object", "required": ["name", "format"], "title": "TGIChatModelRequest", "description": "Mapping of the model for the OpenAI-compatible endpoint.\n\nAttributes:\n type (str): The type of the model, e.g. \"chat\"\n name (str): The name of the model. This name will be used both to load model configuration from the HuggingFace Hub and in the OpenAI-compatible endpoint.\n format (str): The format of the model, e.g. \"tgi\" if the model is served with HuggingFace's Text Generation Inference.\n chat_template (Optional[str]): The custom prompt template for the model. If not specified, the default prompt template from the HuggingFace Hub configuration will be used.\n eos_token (Optional[str]): The custom end of sentence token. If not specified, the default end of sentence token from the HuggingFace Hub configuration will be used."}, "TaskConfigurationRequest": {"properties": {"nodes": {"type": "integer", "minimum": 1.0, "title": "Nodes", "description": "Number of nodes", "default": 1}, "ports": {"items": {"anyOf": [{"type": "integer", "maximum": 65536.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?:[0-9]+|\\*):[0-9]+$"}, {"$ref": "#/components/schemas/PortMappingRequest"}]}, "type": "array", "title": "Ports", "description": "Port numbers/mapping to expose", "default": []}, "commands": {"items": {"type": "string"}, "type": "array", "title": "Commands", "description": "The shell commands to run", "default": []}, "type": {"type": "string", "enum": ["task"], "title": "Type", "default": "task"}, "name": {"type": "string", "title": "Name", "description": "The run name. If not specified, a random name is generated"}, "image": {"type": "string", "title": "Image", "description": "The name of the Docker image to run"}, "user": {"type": "string", "title": "User", "description": "The user inside the container, `user_name_or_id[:group_name_or_id]` (e.g., `ubuntu`, `1000:1000`). Defaults to the default user from the `image`"}, "privileged": {"type": "boolean", "title": "Privileged", "description": "Run the container in privileged mode", "default": false}, "entrypoint": {"type": "string", "title": "Entrypoint", "description": "The Docker entrypoint"}, "working_dir": {"type": "string", "title": "Working Dir", "description": "The absolute path to the working directory inside the container. Defaults to the `image`'s default working directory"}, "home_dir": {"type": "string", "title": "Home Dir", "default": "/root"}, "registry_auth": {"allOf": [{"$ref": "#/components/schemas/RegistryAuthRequest"}], "title": "Registry Auth", "description": "Credentials for pulling a private Docker image"}, "python": {"allOf": [{"$ref": "#/components/schemas/PythonVersion"}], "description": "The major version of Python. Mutually exclusive with `image` and `docker`"}, "nvcc": {"type": "boolean", "title": "Nvcc", "description": "Use image with NVIDIA CUDA Compiler (NVCC) included. Mutually exclusive with `image` and `docker`"}, "single_branch": {"type": "boolean", "title": "Single Branch", "description": "Whether to clone and track only the current branch or all remote branches. Relevant only when using remote Git repos. Defaults to `false` for dev environments and to `true` for tasks and services"}, "env": {"allOf": [{"$ref": "#/components/schemas/Env"}], "title": "Env", "description": "The mapping or the list of environment variables", "default": {"__root__": {}}}, "shell": {"type": "string", "title": "Shell", "description": "The shell used to run commands. Allowed values are `sh`, `bash`, or an absolute path, e.g., `/usr/bin/zsh`. Defaults to `/bin/sh` if the `image` is specified, `/bin/bash` otherwise"}, "resources": {"allOf": [{"$ref": "#/components/schemas/ResourcesSpecRequest"}], "title": "Resources", "description": "The resources requirements to run the configuration", "default": {"cpu": {"min": 2}, "memory": {"min": 8.0}, "disk": {"size": {"min": 100.0}}}}, "priority": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Priority", "description": "The priority of the run, an integer between `0` and `100`. `dstack` tries to provision runs with higher priority first. Defaults to `0`"}, "volumes": {"items": {"anyOf": [{"$ref": "#/components/schemas/VolumeMountPointRequest"}, {"$ref": "#/components/schemas/InstanceMountPointRequest"}, {"type": "string"}]}, "type": "array", "title": "Volumes", "description": "The volumes mount points", "default": []}, "docker": {"type": "boolean", "title": "Docker", "description": "Use Docker inside the container. Mutually exclusive with `image`, `python`, and `nvcc`. Overrides `privileged`"}, "repos": {"items": {"$ref": "#/components/schemas/RepoSpecRequest"}, "type": "array", "title": "Repos", "description": "The list of Git repos", "default": []}, "files": {"items": {"anyOf": [{"$ref": "#/components/schemas/FilePathMappingRequest"}, {"type": "string"}]}, "type": "array", "title": "Files", "description": "The local to container file path mappings", "default": []}, "setup": {"items": {"type": "string"}, "type": "array", "title": "Setup", "default": []}, "backends": {"items": {"$ref": "#/components/schemas/BackendType"}, "type": "array", "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)"}, "availability_zones": {"items": {"type": "string"}, "type": "array", "title": "Availability Zones", "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)"}, "instance_types": {"items": {"type": "string"}, "type": "array", "title": "Instance Types", "description": "The cloud-specific instance types to consider for provisioning (e.g., `[p3.8xlarge, n1-standard-4]`)"}, "reservation": {"type": "string", "title": "Reservation", "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations"}, "spot_policy": {"allOf": [{"$ref": "#/components/schemas/SpotPolicy"}], "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`"}, "retry": {"anyOf": [{"$ref": "#/components/schemas/ProfileRetryRequest"}, {"type": "boolean"}], "title": "Retry", "description": "The policy for resubmitting the run. Defaults to `false`"}, "max_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Max Duration", "description": "The maximum duration of a run (e.g., `2h`, `1d`, etc) in a running state, excluding provisioning and pulling. After it elapses, the run is automatically stopped. Use `off` for unlimited duration. Defaults to `off`"}, "stop_duration": {"anyOf": [{"type": "string", "enum": ["off"]}, {"type": "integer"}, {"type": "boolean"}, {"type": "string"}], "title": "Stop Duration", "description": "The maximum duration of a run graceful stopping. After it elapses, the run is automatically forced stopped. This includes force detaching volumes used by the run. Use `off` for unlimited duration. Defaults to `5m`"}, "max_price": {"type": "number", "exclusiveMinimum": 0.0, "title": "Max Price", "description": "The maximum instance price per hour, in dollars"}, "creation_policy": {"allOf": [{"$ref": "#/components/schemas/CreationPolicy"}], "description": "The policy for using instances from fleets: `reuse`, `reuse-or-create`. Defaults to `reuse-or-create`"}, "idle_duration": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Idle Duration", "description": "Time to wait before terminating idle instances. Instances are not terminated if the fleet is already at `nodes.min`. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration"}, "utilization_policy": {"allOf": [{"$ref": "#/components/schemas/UtilizationPolicyRequest"}], "title": "Utilization Policy", "description": "Run termination policy based on utilization"}, "startup_order": {"allOf": [{"$ref": "#/components/schemas/StartupOrder"}], "description": "The order in which master and workers jobs are started: `any`, `master-first`, `workers-first`. Defaults to `any`"}, "stop_criteria": {"allOf": [{"$ref": "#/components/schemas/StopCriteria"}], "description": "The criteria determining when a multi-node run should be considered finished: `all-done`, `master-done`. Defaults to `all-done`"}, "schedule": {"allOf": [{"$ref": "#/components/schemas/ScheduleRequest"}], "title": "Schedule", "description": "The schedule for starting the run at specified time"}, "fleets": {"items": {"type": "string"}, "type": "array", "title": "Fleets", "description": "The fleets considered for reuse"}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them"}}, "additionalProperties": false, "type": "object", "title": "TaskConfigurationRequest"}, "TensorDockAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The API key"}, "api_token": {"type": "string", "title": "Api Token", "description": "The API token"}}, "additionalProperties": false, "type": "object", "required": ["api_key", "api_token"], "title": "TensorDockAPIKeyCredsRequest"}, "TensorDockBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["tensordock"], "title": "Type", "description": "The type of backend", "default": "tensordock"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of TensorDock regions. Omit to use all regions"}}, "additionalProperties": false, "type": "object", "title": "TensorDockBackendConfigRequest"}, "TensorDockBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["tensordock"], "title": "Type", "description": "The type of backend", "default": "tensordock"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of TensorDock regions. Omit to use all regions"}, "creds": {"allOf": [{"$ref": "#/components/schemas/TensorDockAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "TensorDockBackendConfigWithCredsRequest"}, "UnixUserRequest": {"properties": {"uid": {"type": "integer", "minimum": 0.0, "title": "Uid", "description": "User ID"}, "gid": {"type": "integer", "minimum": 0.0, "title": "Gid", "description": "Group ID"}, "username": {"type": "string", "minLength": 1, "title": "Username", "description": "User name"}, "groupname": {"type": "string", "minLength": 1, "title": "Groupname", "description": "Group name"}}, "additionalProperties": false, "type": "object", "title": "UnixUserRequest"}, "UpdateBackendYAMLRequestRequest": {"properties": {"config_yaml": {"type": "string", "title": "Config Yaml"}}, "additionalProperties": false, "type": "object", "required": ["config_yaml"], "title": "UpdateBackendYAMLRequestRequest"}, "UpdateProjectRequestRequest": {"properties": {"is_public": {"type": "boolean", "title": "Is Public"}}, "additionalProperties": false, "type": "object", "required": ["is_public"], "title": "UpdateProjectRequestRequest"}, "UserPermissionsRequest": {"properties": {"can_create_projects": {"type": "boolean", "title": "Can Create Projects"}}, "additionalProperties": false, "type": "object", "required": ["can_create_projects"], "title": "UserPermissionsRequest"}, "UserRequest": {"properties": {"id": {"type": "string", "format": "uuid4", "title": "Id"}, "username": {"type": "string", "title": "Username"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "global_role": {"$ref": "#/components/schemas/GlobalRole"}, "email": {"type": "string", "title": "Email"}, "active": {"type": "boolean", "title": "Active"}, "permissions": {"$ref": "#/components/schemas/UserPermissionsRequest"}, "ssh_public_key": {"type": "string", "title": "Ssh Public Key"}}, "additionalProperties": false, "type": "object", "required": ["id", "username", "global_role", "active", "permissions"], "title": "UserRequest"}, "UserTokenCredsRequest": {"properties": {"token": {"type": "string", "title": "Token"}}, "additionalProperties": false, "type": "object", "required": ["token"], "title": "UserTokenCredsRequest"}, "UserWithCredsRequest": {"properties": {"id": {"type": "string", "format": "uuid4", "title": "Id"}, "username": {"type": "string", "title": "Username"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "global_role": {"$ref": "#/components/schemas/GlobalRole"}, "email": {"type": "string", "title": "Email"}, "active": {"type": "boolean", "title": "Active"}, "permissions": {"$ref": "#/components/schemas/UserPermissionsRequest"}, "ssh_public_key": {"type": "string", "title": "Ssh Public Key"}, "creds": {"$ref": "#/components/schemas/UserTokenCredsRequest"}, "ssh_private_key": {"type": "string", "title": "Ssh Private Key"}}, "additionalProperties": false, "type": "object", "required": ["id", "username", "global_role", "active", "permissions", "creds"], "title": "UserWithCredsRequest"}, "UsersInfoListRequest": {"properties": {"total_count": {"type": "integer", "title": "Total Count"}, "users": {"items": {"$ref": "#/components/schemas/UserRequest"}, "type": "array", "title": "Users"}}, "additionalProperties": false, "type": "object", "required": ["users"], "title": "UsersInfoListRequest"}, "UtilizationPolicyRequest": {"properties": {"min_gpu_utilization": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Min Gpu Utilization", "description": "Minimum required GPU utilization, percent. If any GPU has utilization below specified value during the whole time window, the run is terminated"}, "time_window": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Time Window", "description": "The time window of metric samples taking into account to measure utilization (e.g., `30m`, `1h`). Minimum is `5m`"}}, "additionalProperties": false, "type": "object", "required": ["min_gpu_utilization", "time_window"], "title": "UtilizationPolicyRequest"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}, "VastAIAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The API key"}}, "additionalProperties": false, "type": "object", "required": ["api_key"], "title": "VastAIAPIKeyCredsRequest"}, "VastAIBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["vastai"], "title": "Type", "description": "The type of backend", "default": "vastai"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of VastAI regions. Omit to use all regions"}}, "additionalProperties": false, "type": "object", "title": "VastAIBackendConfigRequest"}, "VastAIBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["vastai"], "title": "Type", "description": "The type of backend", "default": "vastai"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of VastAI regions. Omit to use all regions"}, "creds": {"allOf": [{"$ref": "#/components/schemas/VastAIAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "VastAIBackendConfigWithCredsRequest"}, "VerdaAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "client_id": {"type": "string", "title": "Client Id", "description": "The client ID"}, "client_secret": {"type": "string", "title": "Client Secret", "description": "The client secret"}}, "additionalProperties": false, "type": "object", "required": ["client_id", "client_secret"], "title": "VerdaAPIKeyCredsRequest"}, "VerdaBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["verda", "datacrunch"], "title": "Type", "description": "The type of backend"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Verda regions. Omit to use all regions"}}, "additionalProperties": false, "type": "object", "required": ["type"], "title": "VerdaBackendConfigRequest"}, "VerdaBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["verda", "datacrunch"], "title": "Type", "description": "The type of backend"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Verda regions. Omit to use all regions"}, "creds": {"allOf": [{"$ref": "#/components/schemas/VerdaAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["type", "creds"], "title": "VerdaBackendConfigWithCredsRequest"}, "VirtualRepoInfoRequest": {"properties": {"repo_type": {"type": "string", "enum": ["virtual"], "title": "Repo Type", "default": "virtual"}}, "additionalProperties": false, "type": "object", "title": "VirtualRepoInfoRequest"}, "VirtualRunRepoDataRequest": {"properties": {"repo_type": {"type": "string", "enum": ["virtual"], "title": "Repo Type", "default": "virtual"}}, "additionalProperties": false, "type": "object", "title": "VirtualRunRepoDataRequest"}, "VolumeAttachmentDataRequest": {"properties": {"device_name": {"type": "string", "title": "Device Name"}}, "additionalProperties": false, "type": "object", "title": "VolumeAttachmentDataRequest"}, "VolumeAttachmentRequest": {"properties": {"instance": {"$ref": "#/components/schemas/VolumeInstanceRequest"}, "attachment_data": {"$ref": "#/components/schemas/VolumeAttachmentDataRequest"}}, "additionalProperties": false, "type": "object", "required": ["instance"], "title": "VolumeAttachmentRequest"}, "VolumeConfigurationRequest": {"properties": {"type": {"type": "string", "enum": ["volume"], "title": "Type", "default": "volume"}, "name": {"type": "string", "title": "Name", "description": "The volume name"}, "backend": {"allOf": [{"$ref": "#/components/schemas/BackendType"}], "description": "The volume backend"}, "region": {"type": "string", "title": "Region", "description": "The volume region"}, "availability_zone": {"type": "string", "title": "Availability Zone", "description": "The volume availability zone"}, "size": {"type": "number", "title": "Size", "description": "The volume size. Must be specified when creating new volumes"}, "volume_id": {"type": "string", "title": "Volume Id", "description": "The volume ID. Must be specified when registering external volumes"}, "auto_cleanup_duration": {"anyOf": [{"type": "integer"}, {"type": "string"}], "title": "Auto Cleanup Duration", "description": "Time to wait after volume is no longer used by any job before deleting it. Defaults to keep the volume indefinitely. Use the value 'off' or -1 to disable auto-cleanup."}, "tags": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Tags", "description": "The custom tags to associate with the volume. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them"}}, "additionalProperties": false, "type": "object", "required": ["backend", "region"], "title": "VolumeConfigurationRequest"}, "VolumeInstanceRequest": {"properties": {"name": {"type": "string", "title": "Name"}, "fleet_name": {"type": "string", "title": "Fleet Name"}, "instance_num": {"type": "integer", "title": "Instance Num"}, "instance_id": {"type": "string", "title": "Instance Id"}}, "additionalProperties": false, "type": "object", "required": ["name", "instance_num"], "title": "VolumeInstanceRequest"}, "VolumeMountPointRequest": {"properties": {"name": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "title": "Name", "description": "The network volume name or the list of network volume names to mount. If a list is specified, one of the volumes in the list will be mounted. Specify volumes from different backends/regions to increase availability"}, "path": {"type": "string", "title": "Path", "description": "The absolute container path to mount the volume at"}}, "additionalProperties": false, "type": "object", "required": ["name", "path"], "title": "VolumeMountPointRequest"}, "VolumeProvisioningDataRequest": {"properties": {"backend": {"$ref": "#/components/schemas/BackendType"}, "volume_id": {"type": "string", "title": "Volume Id"}, "size_gb": {"type": "integer", "title": "Size Gb"}, "availability_zone": {"type": "string", "title": "Availability Zone"}, "price": {"type": "number", "title": "Price"}, "attachable": {"type": "boolean", "title": "Attachable", "default": true}, "detachable": {"type": "boolean", "title": "Detachable", "default": true}, "backend_data": {"type": "string", "title": "Backend Data"}}, "additionalProperties": false, "type": "object", "required": ["volume_id", "size_gb"], "title": "VolumeProvisioningDataRequest"}, "VolumeRequest": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "name": {"type": "string", "title": "Name"}, "user": {"type": "string", "title": "User"}, "project_name": {"type": "string", "title": "Project Name"}, "configuration": {"$ref": "#/components/schemas/VolumeConfigurationRequest"}, "external": {"type": "boolean", "title": "External"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "last_processed_at": {"type": "string", "format": "date-time", "title": "Last Processed At"}, "status": {"$ref": "#/components/schemas/VolumeStatus"}, "status_message": {"type": "string", "title": "Status Message"}, "deleted": {"type": "boolean", "title": "Deleted"}, "deleted_at": {"type": "string", "format": "date-time", "title": "Deleted At"}, "volume_id": {"type": "string", "title": "Volume Id"}, "provisioning_data": {"$ref": "#/components/schemas/VolumeProvisioningDataRequest"}, "cost": {"type": "number", "title": "Cost", "default": 0}, "attachments": {"items": {"$ref": "#/components/schemas/VolumeAttachmentRequest"}, "type": "array", "title": "Attachments"}, "attachment_data": {"$ref": "#/components/schemas/VolumeAttachmentDataRequest"}}, "additionalProperties": false, "type": "object", "required": ["id", "name", "user", "project_name", "configuration", "external", "created_at", "last_processed_at", "status", "deleted"], "title": "VolumeRequest"}, "VolumeStatus": {"type": "string", "enum": ["submitted", "provisioning", "active", "failed"], "title": "VolumeStatus", "description": "An enumeration."}, "VultrAPIKeyCredsRequest": {"properties": {"type": {"type": "string", "enum": ["api_key"], "title": "Type", "description": "The type of credentials", "default": "api_key"}, "api_key": {"type": "string", "title": "Api Key", "description": "The API key"}}, "additionalProperties": false, "type": "object", "required": ["api_key"], "title": "VultrAPIKeyCredsRequest"}, "VultrBackendConfigRequest": {"properties": {"type": {"type": "string", "enum": ["vultr"], "title": "Type", "description": "The type of backend", "default": "vultr"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Vultr regions. Omit to use all regions"}}, "additionalProperties": false, "type": "object", "title": "VultrBackendConfigRequest"}, "VultrBackendConfigWithCredsRequest": {"properties": {"type": {"type": "string", "enum": ["vultr"], "title": "Type", "description": "The type of backend", "default": "vultr"}, "regions": {"items": {"type": "string"}, "type": "array", "title": "Regions", "description": "The list of Vultr regions. Omit to use all regions"}, "creds": {"allOf": [{"$ref": "#/components/schemas/VultrAPIKeyCredsRequest"}], "title": "Creds", "description": "The credentials"}}, "additionalProperties": false, "type": "object", "required": ["creds"], "title": "VultrBackendConfigWithCredsRequest"}}, "securitySchemes": {"HTTPBearer": {"type": "http", "scheme": "bearer"}}}}
diff --git a/docs/examples.md b/docs/examples.md
index 6032e72a8b..575c747ca2 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -1,4 +1,6 @@
---
+title: Examples
+description: Collection of examples for training, inference, and clusters
#template: examples.html
hide:
- navigation
diff --git a/examples/accelerators/amd/README.md b/examples/accelerators/amd/README.md
index 863a0462bf..a660acddc7 100644
--- a/examples/accelerators/amd/README.md
+++ b/examples/accelerators/amd/README.md
@@ -1,3 +1,8 @@
+---
+title: AMD
+description: Deploying and fine-tuning models on AMD MI300X GPUs using TGI, vLLM, and Axolotl
+---
+
# AMD
`dstack` supports running dev environments, tasks, and services on AMD GPUs.
diff --git a/examples/accelerators/intel/README.md b/examples/accelerators/intel/README.md
index 0220a74ce8..0e2a629f2f 100644
--- a/examples/accelerators/intel/README.md
+++ b/examples/accelerators/intel/README.md
@@ -1,6 +1,11 @@
+---
+title: Intel Gaudi
+description: Deploying and fine-tuning models on Intel Gaudi accelerators using TGI, vLLM, and Optimum
+---
+
# Intel Gaudi
-`dstack` supports running dev environments, tasks, and services on Intel Gaudi GPUs via
+`dstack` supports running dev environments, tasks, and services on Intel Gaudi GPUs via
[SSH fleets](https://dstack.ai/docs/concepts/fleets#ssh-fleets).
## Deployment
diff --git a/examples/accelerators/tenstorrent/README.md b/examples/accelerators/tenstorrent/README.md
index bbb0b2207e..95f3f2b6c9 100644
--- a/examples/accelerators/tenstorrent/README.md
+++ b/examples/accelerators/tenstorrent/README.md
@@ -1,12 +1,11 @@
---
-title: "Tenstorrent"
-description: "You can now use SSH fleets to run dev environments, tasks, and services on Tenstorrent."
-image: https://dstack.ai/static-assets/static-assets/images/dstack-tenstorrent-min.png
+title: Tenstorrent
+description: Running dev environments, tasks, and services on Tenstorrent Wormhole accelerators
---
# Tenstorrent
-`dstack` supports running dev environments, tasks, and services on Tenstorrent
+`dstack` supports running dev environments, tasks, and services on Tenstorrent
[Wormwhole](https://tenstorrent.com/en/hardware/wormhole) accelerators via SSH fleets.
diff --git a/examples/accelerators/tpu/README.md b/examples/accelerators/tpu/README.md
index da16326586..98982a919a 100644
--- a/examples/accelerators/tpu/README.md
+++ b/examples/accelerators/tpu/README.md
@@ -1,3 +1,8 @@
+---
+title: TPU
+description: Deploying and fine-tuning models on Google Cloud TPUs using Optimum TPU and vLLM
+---
+
# TPU
If you've configured the `gcp` backend in `dstack`, you can run dev environments, tasks, and services on [TPUs](https://cloud.google.com/tpu/docs/intro-to-tpu).
diff --git a/examples/clusters/aws/README.md b/examples/clusters/aws/README.md
index f7cb622704..b6319e214e 100644
--- a/examples/clusters/aws/README.md
+++ b/examples/clusters/aws/README.md
@@ -1,6 +1,11 @@
+---
+title: AWS
+description: High-performance distributed training on AWS using Elastic Fabric Adapter (EFA)
+---
+
# AWS
-In this guide, we’ll walk through how to run high-performance distributed training on AWS using [Amazon Elastic Fabric Adapter (EFA)](https://aws.amazon.com/hpc/efa/) with `dstack`.
+In this guide, we'll walk through how to run high-performance distributed training on AWS using [Amazon Elastic Fabric Adapter (EFA)](https://aws.amazon.com/hpc/efa/) with `dstack`.
## Overview
diff --git a/examples/clusters/crusoe/README.md b/examples/clusters/crusoe/README.md
index fb6f4b1a7f..b34c4aef34 100644
--- a/examples/clusters/crusoe/README.md
+++ b/examples/clusters/crusoe/README.md
@@ -1,5 +1,6 @@
---
-title: Distributed workload orchestration on Crusoe with dstack
+title: Crusoe
+description: Setting up Crusoe clusters using Managed Kubernetes or VMs with InfiniBand support
---
# Crusoe
diff --git a/examples/clusters/gcp/README.md b/examples/clusters/gcp/README.md
index 9f7bad984d..a4610235b2 100644
--- a/examples/clusters/gcp/README.md
+++ b/examples/clusters/gcp/README.md
@@ -1,3 +1,8 @@
+---
+title: GCP
+description: Creating and using GPU clusters on GCP with GPUDirect-TCPX and RoCE support
+---
+
# GCP
This example shows how to create and use clusters on GCP.
diff --git a/examples/clusters/lambda/README.md b/examples/clusters/lambda/README.md
index a78465fbac..50a98bf6ed 100644
--- a/examples/clusters/lambda/README.md
+++ b/examples/clusters/lambda/README.md
@@ -1,5 +1,6 @@
---
-title: Distributed workload orchestration on Lambda with dstack
+title: Lambda
+description: Setting up Lambda clusters using Kubernetes or 1-Click Clusters with fast interconnect
---
# Lambda
diff --git a/examples/clusters/nccl-rccl-tests/README.md b/examples/clusters/nccl-rccl-tests/README.md
index 0a2a138b1a..1d9166591e 100644
--- a/examples/clusters/nccl-rccl-tests/README.md
+++ b/examples/clusters/nccl-rccl-tests/README.md
@@ -1,3 +1,8 @@
+---
+title: NCCL/RCCL tests
+description: Running NCCL and RCCL tests to validate cluster network bandwidth
+---
+
# NCCL/RCCL tests
This example shows how to run [NCCL](https://github.com/NVIDIA/nccl-tests) or [RCCL](https://github.com/ROCm/rccl-tests) tests on a cluster using [distributed tasks](https://dstack.ai/docs/concepts/tasks#distributed-tasks).
diff --git a/examples/distributed-training/axolotl/README.md b/examples/distributed-training/axolotl/README.md
index 1454732ad5..593d4142b2 100644
--- a/examples/distributed-training/axolotl/README.md
+++ b/examples/distributed-training/axolotl/README.md
@@ -1,3 +1,8 @@
+---
+title: Axolotl
+description: Distributed fine-tuning with Axolotl and FSDP across multiple nodes
+---
+
# Axolotl
This example walks you through how to run distributed fine-tune using [Axolotl](https://github.com/axolotl-ai-cloud/axolotl) and [distributed tasks](https://dstack.ai/docs/concepts/tasks#distributed-tasks).
diff --git a/examples/distributed-training/ray-ragen/README.md b/examples/distributed-training/ray-ragen/README.md
index 32ce0173fd..f7bd80d5c2 100644
--- a/examples/distributed-training/ray-ragen/README.md
+++ b/examples/distributed-training/ray-ragen/README.md
@@ -1,6 +1,11 @@
+---
+title: Ray + RAGEN
+description: Multi-node agent fine-tuning using RAGEN with Ray and verl for reinforcement learning
+---
+
# Ray + RAGEN
-This example shows how use `dstack` and [RAGEN](https://github.com/RAGEN-AI/RAGEN)
+This example shows how use `dstack` and [RAGEN](https://github.com/RAGEN-AI/RAGEN)
to fine-tune an agent on multiple nodes.
Under the hood `RAGEN` uses [verl](https://github.com/volcengine/verl) for Reinforcement Learning and [Ray](https://docs.ray.io/en/latest/) for distributed training.
diff --git a/examples/distributed-training/trl/README.md b/examples/distributed-training/trl/README.md
index 9df482da52..84ac7dd6ba 100644
--- a/examples/distributed-training/trl/README.md
+++ b/examples/distributed-training/trl/README.md
@@ -1,3 +1,8 @@
+---
+title: TRL
+description: Distributed fine-tuning with TRL, Accelerate, and DeepSpeed
+---
+
# TRL
This example walks you through how to run distributed fine-tune using [TRL](https://github.com/huggingface/trl), [Accelerate](https://github.com/huggingface/accelerate) and [Deepspeed](https://github.com/deepspeedai/DeepSpeed).
diff --git a/examples/inference/nim/README.md b/examples/inference/nim/README.md
index 1b125dda83..ec323beb94 100644
--- a/examples/inference/nim/README.md
+++ b/examples/inference/nim/README.md
@@ -1,6 +1,6 @@
---
title: NVIDIA NIM
-description: "This example shows how to deploy DeepSeek-R1-Distill-Llama-8B to any cloud or on-premises environment using NVIDIA NIM and dstack."
+description: Deploying DeepSeek-R1-Distill-Llama-8B using NVIDIA NIM
---
# NVIDIA NIM
diff --git a/examples/inference/sglang/README.md b/examples/inference/sglang/README.md
index d0c42bf2ee..bc95adc042 100644
--- a/examples/inference/sglang/README.md
+++ b/examples/inference/sglang/README.md
@@ -1,3 +1,8 @@
+---
+title: SGLang
+description: Deploying DeepSeek-R1-Distill-Llama models using SGLang on NVIDIA and AMD GPUs
+---
+
# SGLang
This example shows how to deploy DeepSeek-R1-Distill-Llama 8B and 70B using [SGLang](https://github.com/sgl-project/sglang) and `dstack`.
diff --git a/examples/inference/tgi/README.md b/examples/inference/tgi/README.md
index 6984ec2ff6..971929bf61 100644
--- a/examples/inference/tgi/README.md
+++ b/examples/inference/tgi/README.md
@@ -1,6 +1,6 @@
---
title: HuggingFace TGI
-description: "This example shows how to deploy Llama 4 Scout to any cloud or on-premises environment using HuggingFace TGI and dstack."
+description: Deploying Llama 4 Scout using HuggingFace Text Generation Inference
---
# HuggingFace TGI
diff --git a/examples/inference/trtllm/README.md b/examples/inference/trtllm/README.md
index 02ae55f464..58f0d97ebb 100644
--- a/examples/inference/trtllm/README.md
+++ b/examples/inference/trtllm/README.md
@@ -1,6 +1,6 @@
---
title: TensorRT-LLM
-description: "This example shows how to deploy Deepseek models to any cloud or on-premises environment using NVIDIA TensorRT-LLM and dstack."
+description: Deploying DeepSeek R1 and distilled models using NVIDIA TensorRT-LLM with Triton
---
# TensorRT-LLM
diff --git a/examples/inference/vllm/README.md b/examples/inference/vllm/README.md
index 12bfd18ac1..c5a635b3a4 100644
--- a/examples/inference/vllm/README.md
+++ b/examples/inference/vllm/README.md
@@ -1,5 +1,6 @@
---
-description: "This example shows how to deploy Llama 3.1 to any cloud or on-premises environment using vLLM and dstack."
+title: vLLM
+description: Deploying Llama 3.1 8B using vLLM with OpenAI-compatible API
---
# vLLM
diff --git a/examples/models/wan22/README.md b/examples/models/wan22/README.md
index 47e9f4230d..07d3b2c1ee 100644
--- a/examples/models/wan22/README.md
+++ b/examples/models/wan22/README.md
@@ -1,3 +1,8 @@
+---
+title: Wan2.2
+description: Text-to-video generation using the Wan2.2 T2V-A14B foundational video model
+---
+
# Wan2.2
[Wan2.2](https://github.com/Wan-Video/Wan2.2) is an open-source SOTA foundational video model. This example shows how to run the T2V-A14B model variant via `dstack` for text-to-video generation.
diff --git a/examples/single-node-training/axolotl/README.md b/examples/single-node-training/axolotl/README.md
index c9c7917f0a..f8ae04f7ce 100644
--- a/examples/single-node-training/axolotl/README.md
+++ b/examples/single-node-training/axolotl/README.md
@@ -1,3 +1,8 @@
+---
+title: Axolotl
+description: Fine-tuning models with Axolotl using FSDP and QLoRA
+---
+
# Axolotl
This example shows how to use [Axolotl](https://github.com/OpenAccess-AI-Collective/axolotl) with `dstack` to fine-tune 4-bit Quantized `Llama-4-Scout-17B-16E` using SFT with FSDP and QLoRA.
diff --git a/examples/single-node-training/trl/README.md b/examples/single-node-training/trl/README.md
index 2084082818..f5cf7f5a4a 100644
--- a/examples/single-node-training/trl/README.md
+++ b/examples/single-node-training/trl/README.md
@@ -1,3 +1,8 @@
+---
+title: TRL
+description: Fine-tuning Llama with TRL using SFT and QLoRA
+---
+
# TRL
This example walks you through how to use [TRL](https://github.com/huggingface/trl) to fine-tune `Llama-3.1-8B` with `dstack` using SFT with QLoRA.
diff --git a/mkdocs.yml b/mkdocs.yml
index c7afe30256..c98af0a2dd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -13,6 +13,9 @@ edit_uri: edit/master/docs/
#Copyright
copyright: © 2025 dstack
+hooks:
+ - scripts/docs/hooks.py
+
# Configuration
theme:
name: material
@@ -60,44 +63,6 @@ theme:
# Plugins
plugins:
- - llmstxt-md:
- enable_copy_button: false
- sections:
- Getting started:
- - docs/index.md: What dstack is
- - docs/installation/index.md: How to set up the dstack server and the dstack CLI
- - docs/quickstart.md: How to get started and submit your first run
- Concepts:
- - docs/concepts/backends.md: How to configure backends - to connect dstack to cloud providers, Kubernetes clusters, etc
- - docs/concepts/fleets.md: How to create and manage fleets - required before submitting runs
- - docs/concepts/dev-environments.md: How to create dev environments - for interactive development using a desktop IDE
- - docs/concepts/tasks.md: How to run tasks - for training or fine-tuning, including distributed tasks
- - docs/concepts/services.md: How to deploy services - for model inference or web apps
- - docs/concepts/volumes.md: How to manage volumes - for persistent storage or caching
- - docs/concepts/gateways.md: How to manage gateways - enabling auto-scaling, rate limits, and custom domains
- - docs/concepts/secrets.md: How to manage secrets - for API keys or other sensitive data
- - docs/concepts/projects.md: How to manage projects - for managing separate teams
- - docs/concepts/metrics.md: How to access metrics - for monitoring of runs/jobs/fleets, hardware usage (e.g. GPU util), etc
- - docs/concepts/events.md: How to access events - aka audit log - for tracking state changes, resource creation/deletion, etc
- Guides:
- - docs/guides/server-deployment.md: Detailed guide on how to deploy the dstack server
- - docs/guides/troubleshooting.md: Common issues and how to troubleshoot them
- - docs/guides/protips.md: Pro tips - tips and tricks to use dstack more efficiently
- Examples:
- - examples/single-node-training/trl/index.md: TRL
- - examples/single-node-training/axolotl/index.md: Axolotl
- - examples/distributed-training/trl/index.md: TRL
- - examples/distributed-training/axolotl/index.md: Axolotl
- - examples/distributed-training/ray-ragen/index.md: Ray+RAGEN
- - examples/clusters/nccl-rccl-tests/index.md: NCCL/RCCL tests
- - examples/inference/sglang/index.md: SGLang
- - examples/inference/vllm/index.md: vLLM
- - examples/inference/nim/index.md: NIM
- - examples/inference/trtllm/index.md: TensorRT-LLM
- Accelerators:
- - examples/accelerators/amd/index.md: AMD
- - examples/accelerators/tpu/index.md: TPU
- - examples/accelerators/tenstorrent/index.md: Tenstorrent
- blog:
blog_dir: blog
post_url_format: "{slug}"
@@ -172,11 +137,10 @@ plugins:
"docs/guides/migration.md": "docs/guides/upgrade.md"
- typeset
- gen-files:
+ # TODO: convert these to hooks (schema reference migrated)
scripts: # always relative to mkdocs.yml
- - scripts/docs/gen_examples.py
- scripts/docs/gen_cli_reference.py
- scripts/docs/gen_openapi_reference.py
- - scripts/docs/gen_schema_reference.py
- scripts/docs/gen_rest_plugin_spec_reference.py
- mkdocstrings:
handlers:
@@ -184,7 +148,8 @@ plugins:
options:
docstring_style: google
show_source: false
- - render_swagger
+ - render_swagger:
+ allow_arbitrary_locations: true
# Extensions
markdown_extensions:
@@ -258,7 +223,7 @@ nav:
- Docs:
- Getting started:
- Overview: docs/index.md
- - Installation: docs/installation/index.md
+ - Installation: docs/installation.md
- Quickstart: docs/quickstart.md
- Concepts:
- Backends: docs/concepts/backends.md
@@ -316,6 +281,7 @@ nav:
- Python API: docs/reference/plugins/python/index.md
- REST API: docs/reference/plugins/rest/index.md
- llms-full.txt: https://dstack.ai/llms-full.txt
+ - skill.md: https://dstack.ai/skill.md
- Examples:
- examples.md
- Single-node training:
diff --git a/pyproject.toml b/pyproject.toml
index 748b5211a1..d9becf693e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -161,7 +161,6 @@ dev = [
"mkdocs-gen-files; python_version >= '3.11'",
"mkdocstrings[python]; python_version >= '3.11'",
"mkdocs-render-swagger-plugin; python_version >= '3.11'",
- "mkdocs-llmstxt-md; python_version >= '3.11'"
]
[project.optional-dependencies]
diff --git a/scripts/docs/gen_llms_files.py b/scripts/docs/gen_llms_files.py
new file mode 100644
index 0000000000..a7eee100ce
--- /dev/null
+++ b/scripts/docs/gen_llms_files.py
@@ -0,0 +1,243 @@
+"""
+Generate llms.txt and llms-full.txt from documentation files.
+
+llms.txt: Generated from mkdocs nav structure with descriptions from page frontmatter
+llms-full.txt: Full concatenation of all markdown content
+"""
+
+import os
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import yaml
+
+# Configuration for which sections to include/exclude
+INCLUDE_SECTIONS = ["Getting started", "Concepts", "Guides", "Examples"]
+EXCLUDE_SECTIONS = ["Reference"]
+
+
+def read_frontmatter(file_path: Path) -> Dict[str, Any]:
+ """Read YAML frontmatter from markdown file."""
+ try:
+ content = file_path.read_text(encoding="utf-8")
+ if content.startswith("---"):
+ parts = content.split("---", 2)
+ if len(parts) >= 3:
+ frontmatter = yaml.safe_load(parts[1])
+ if isinstance(frontmatter, dict):
+ return frontmatter
+ except Exception as e:
+ print(f"Warning: Failed to read frontmatter from {file_path}: {e}")
+ return {}
+
+
+def get_page_info(page_path: str, docs_dir: Path, repo_root: Path) -> Optional[Dict[str, str]]:
+ """Get title and description for a page from its frontmatter."""
+ # page_path is relative to docs_dir
+ full_path = docs_dir / page_path
+
+ # For examples/**/index.md, read from README.md at repo root (same logic as hooks.py)
+ if page_path.startswith("examples/") and page_path.endswith("index.md"):
+ example_dir = Path(page_path).parent
+ readme_path = repo_root / example_dir / "README.md"
+ if readme_path.exists():
+ full_path = readme_path
+
+ if not full_path.exists():
+ return None
+
+ frontmatter = read_frontmatter(full_path)
+
+ # Get title from frontmatter or filename
+ title = frontmatter.get("title")
+ if not title:
+ # Use filename as fallback
+ title = full_path.stem.replace("-", " ").title()
+
+ # Get description from frontmatter
+ description = frontmatter.get("description", "")
+
+ return {"title": title, "description": description}
+
+
+def parse_mkdocs_nav(mkdocs_config: Dict[str, Any], repo_root: str) -> List[Dict[str, Any]]:
+ """Parse mkdocs nav structure and extract relevant sections."""
+ nav = mkdocs_config.get("nav", [])
+ sections = []
+
+ # Get docs_dir from config
+ docs_dir = Path(repo_root) / mkdocs_config.get("docs_dir", "docs")
+ repo_root_path = Path(repo_root)
+
+ def extract_pages(content_list):
+ """Recursively extract all pages from a section's content, including nested subsections."""
+ items = []
+ for item in content_list:
+ if isinstance(item, str):
+ # Plain string path like "examples.md"
+ page_info = get_page_info(item, docs_dir, repo_root_path)
+ if page_info:
+ items.append(
+ {
+ "type": "page",
+ "title": page_info["title"],
+ "path": item,
+ "description": page_info["description"],
+ }
+ )
+ elif isinstance(item, dict):
+ for title, path in item.items():
+ if isinstance(path, str):
+ # Page with title
+ page_info = get_page_info(path, docs_dir, repo_root_path)
+ if page_info:
+ items.append(
+ {
+ "type": "page",
+ "title": title, # Use title from nav
+ "path": path,
+ "description": page_info["description"],
+ }
+ )
+ elif isinstance(path, list):
+ # Nested subsection - create subsection with its pages
+ subsection_items = extract_pages(path)
+ if subsection_items:
+ items.append(
+ {
+ "type": "subsection",
+ "title": title,
+ "items": subsection_items,
+ }
+ )
+ return items
+
+ def process_nav_items(nav_items):
+ """Recursively process nav items to find matching sections."""
+ for item in nav_items:
+ if isinstance(item, dict):
+ for section_name, section_content in item.items():
+ # Check if this section should be included
+ if section_name in INCLUDE_SECTIONS and section_name not in EXCLUDE_SECTIONS:
+ # Extract all pages from this section, including nested subsections
+ items = []
+ if isinstance(section_content, list):
+ items = extract_pages(section_content)
+
+ if items:
+ sections.append(
+ {
+ "title": section_name,
+ "items": items,
+ }
+ )
+
+ # Recursively process nested sections
+ elif isinstance(section_content, list):
+ process_nav_items(section_content)
+
+ process_nav_items(nav)
+ return sections
+
+
+def generate_llms_txt(repo_root: str, mkdocs_config: Dict[str, Any], output_path: str) -> None:
+ """Generate llms.txt from mkdocs nav structure."""
+ # Get title, description, and base_url from mkdocs config
+ title = mkdocs_config.get("site_name", "")
+ description = mkdocs_config.get("site_description", "")
+ base_url = mkdocs_config.get("site_url", "").rstrip("/")
+
+ lines = []
+
+ # Title and description
+ lines.append(f"# {title}\n")
+ lines.append(f"> {description}\n")
+
+ # Parse sections from mkdocs nav
+ sections = parse_mkdocs_nav(mkdocs_config, repo_root)
+
+ # Generate sections
+ def render_items(items, indent_level=0):
+ """Render items (pages and subsections) with proper formatting."""
+ rendered = []
+ for item in items:
+ if item["type"] == "page":
+ # Use .md paths as-is since hooks.py copies them to site
+ url = f"{base_url}/{item['path']}"
+ if item["description"]:
+ rendered.append(f"- [{item['title']}]({url}): {item['description']}")
+ else:
+ rendered.append(f"- [{item['title']}]({url})")
+ elif item["type"] == "subsection":
+ # Render subsection header
+ rendered.append(f"\n### {item['title']}\n")
+ # Render subsection items
+ rendered.extend(render_items(item["items"], indent_level + 1))
+ return rendered
+
+ for section in sections:
+ lines.append(f"## {section['title']}\n")
+ lines.extend(render_items(section["items"]))
+ lines.append("")
+
+ # Write to file
+ with open(output_path, "w", encoding="utf-8") as f:
+ f.write("\n".join(lines))
+
+
+def generate_llms_full_txt(
+ repo_root: str, mkdocs_config: Dict[str, Any], output_path: str
+) -> None:
+ """Generate llms-full.txt by concatenating all pages from llms.txt sections."""
+ content_parts = []
+
+ # Get docs_dir from config
+ docs_dir = Path(repo_root) / mkdocs_config.get("docs_dir", "docs")
+
+ # Parse sections from mkdocs nav (same as llms.txt)
+ sections = parse_mkdocs_nav(mkdocs_config, repo_root)
+
+ def extract_page_paths(items):
+ """Recursively extract all page paths from items (including nested subsections)."""
+ paths = []
+ for item in items:
+ if item["type"] == "page":
+ paths.append(item["path"])
+ elif item["type"] == "subsection":
+ paths.extend(extract_page_paths(item["items"]))
+ return paths
+
+ # Concatenate all pages from all sections
+ for section in sections:
+ for page_path in extract_page_paths(section["items"]):
+ full_path = docs_dir / page_path
+
+ if full_path.is_file():
+ try:
+ content = full_path.read_text(encoding="utf-8")
+ content_parts.append(f"# {page_path}\n\n{content}\n\n")
+ except Exception as e:
+ print(f"Warning: Failed to read {page_path}: {e}")
+ else:
+ print(f"Warning: File not found: {page_path}")
+
+ # Write to file
+ if content_parts:
+ with open(output_path, "w", encoding="utf-8") as f:
+ f.write("".join(content_parts))
+ else:
+ print("Warning: No content found for llms-full.txt")
+
+
+def generate_llms_files(repo_root: str, site_dir: str, mkdocs_config: Dict[str, Any]) -> None:
+ """Generate both llms.txt and llms-full.txt."""
+ llms_txt_path = os.path.join(site_dir, "llms.txt")
+ llms_full_txt_path = os.path.join(site_dir, "llms-full.txt")
+
+ print("Generating llms.txt from mkdocs nav...")
+ generate_llms_txt(repo_root, mkdocs_config, llms_txt_path)
+ print("Generated llms.txt")
+
+ print("Generating llms-full.txt...")
+ generate_llms_full_txt(repo_root, mkdocs_config, llms_full_txt_path)
+ print("Generated llms-full.txt")
diff --git a/scripts/docs/gen_schema_reference.py b/scripts/docs/gen_schema_reference.py
index e7c2cd62e6..8f25283181 100644
--- a/scripts/docs/gen_schema_reference.py
+++ b/scripts/docs/gen_schema_reference.py
@@ -150,6 +150,15 @@ def sub_schema_reference(match: re.Match) -> str:
)
+def expand_schema_references(text: str) -> str:
+ """Expand #SCHEMA# placeholders in markdown text. Used by hooks when gen-files is not used."""
+ return re.sub(
+ r"( *)#SCHEMA#\s+(dstack\.[.a-z_0-9A-Z]+)\s*((?:\n {4}[^\n]+)*)\n",
+ sub_schema_reference,
+ text,
+ )
+
+
def process_file(file: File):
if not fnmatch(file.src_uri, FILE_PATTERN):
return
@@ -161,11 +170,7 @@ def process_file(file: File):
# overrides:
# name:
# required: true
- text = re.sub(
- r"( *)#SCHEMA#\s+(dstack\.[.a-z_0-9A-Z]+)\s*((?:\n {4}[^\n]+)*)\n",
- sub_schema_reference,
- text,
- )
+ text = expand_schema_references(text)
with mkdocs_gen_files.open(file.src_uri, "w") as f:
f.write(text)
diff --git a/scripts/docs/hooks.py b/scripts/docs/hooks.py
new file mode 100644
index 0000000000..4530172d1a
--- /dev/null
+++ b/scripts/docs/hooks.py
@@ -0,0 +1,225 @@
+import importlib.util
+import json
+import logging
+import mimetypes
+import os
+import shutil
+import sys
+
+import yaml
+
+mimetypes.add_type("text/plain", ".md")
+
+log = logging.getLogger("mkdocs")
+
+WELL_KNOWN_SKILLS_DIR = ".well-known/skills"
+SKILL_PATH = ("skills", "dstack", "SKILL.md")
+DISABLE_EXAMPLES_ENV = "DSTACK_DOCS_DISABLE_EXAMPLES"
+SCHEMA_REFERENCE_PREFIX = "docs/reference/"
+
+
+def _expand_schema_references(text: str) -> str:
+ """Lazy load gen_schema_reference by file path so it works regardless of sys.path."""
+ hooks_dir = os.path.dirname(os.path.abspath(__file__))
+ gen_path = os.path.join(hooks_dir, "gen_schema_reference.py")
+ spec = importlib.util.spec_from_file_location("gen_schema_reference", gen_path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"Cannot load {gen_path}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["gen_schema_reference"] = module
+ spec.loader.exec_module(module)
+ return module.expand_schema_references(text)
+
+
+def _get_schema_expanded_content(rel_path, config, src_path=None):
+ """Return expanded markdown for reference/**/*.md that contain #SCHEMA#, else None.
+ If src_path is given (e.g. from on_post_build loop), read from it; else build path from config.
+ """
+ if not rel_path.startswith(SCHEMA_REFERENCE_PREFIX) or not rel_path.endswith(".md"):
+ log.debug(f"Skipping {rel_path}: not in {SCHEMA_REFERENCE_PREFIX} or not .md")
+ return None
+ if src_path is None:
+ repo_root = os.path.dirname(config["config_file_path"])
+ docs_dir = config["docs_dir"]
+ if not os.path.isabs(docs_dir):
+ docs_dir = os.path.join(repo_root, docs_dir)
+ src_path = os.path.join(docs_dir, rel_path.replace("/", os.sep))
+ if not os.path.isfile(src_path):
+ log.debug(f"Skipping {rel_path}: source file not found at {src_path}")
+ return None
+ try:
+ with open(src_path, "r", encoding="utf-8") as f:
+ text = f.read()
+ except OSError as e:
+ log.debug(f"Skipping {rel_path}: error reading file: {e}")
+ return None
+ if "#SCHEMA#" not in text:
+ log.debug(f"Skipping {rel_path}: no #SCHEMA# placeholders found")
+ return None
+ log.debug(f"Expanding schema references in {rel_path}")
+ return _expand_schema_references(text)
+
+
+def _get_materialized_content(rel_path, config):
+ """Return README content for examples/**/index.md stubs, else None."""
+ if os.environ.get(DISABLE_EXAMPLES_ENV):
+ return None
+
+ if rel_path.startswith("examples/") and rel_path.endswith("index.md"):
+ repo_root = os.path.dirname(config["config_file_path"])
+ example_dir = os.path.dirname(rel_path)
+ readme_path = os.path.join(repo_root, example_dir, "README.md")
+
+ if os.path.isfile(readme_path):
+ with open(readme_path, "r", encoding="utf-8") as f:
+ return f.read()
+ return None
+
+
+def on_page_read_source(page, config):
+ """Use README content for example stubs and expanded schema for reference docs when rendering HTML."""
+ rel_path = page.file.src_uri
+ content = _get_materialized_content(rel_path, config)
+ if content is not None:
+ return content
+ content = _get_schema_expanded_content(rel_path, config)
+ if content is not None:
+ return content
+ return None
+
+
+def on_page_context(context, page, config, nav):
+ """Override edit_url only for example stubs so Edit points to the README; other pages use theme default from edit_uri."""
+ repo_url = (config.get("repo_url") or "").rstrip("/")
+ edit_uri = (config.get("edit_uri") or "edit/master/docs/").strip("/")
+ if not repo_url:
+ return context
+ # edit_uri is e.g. "edit/master/docs" -> branch is second segment
+ edit_parts = edit_uri.split("/")
+ branch = edit_parts[1] if len(edit_parts) >= 2 else "master"
+
+ rel_path = page.file.src_uri
+ if rel_path.startswith("examples/") and rel_path.endswith("index.md"):
+ example_dir = os.path.dirname(rel_path)
+ page.edit_url = f"{repo_url}/edit/{branch}/{example_dir}/README.md"
+
+ return context
+
+
+def on_post_build(config):
+ """Copy .md files to site (raw) and write .well-known/skills index."""
+ site_dir = config["site_dir"]
+ docs_dir = config["docs_dir"]
+
+ # Create .nojekyll to prevent GitHub Pages from ignoring .well-known directory
+ nojekyll_path = os.path.join(site_dir, ".nojekyll")
+ with open(nojekyll_path, "w") as f:
+ f.write("")
+
+ # Create _config.yml to explicitly include .well-known directory
+ # This ensures Jekyll (if it runs) includes the .well-known directory
+ config_yml_path = os.path.join(site_dir, "_config.yml")
+ with open(config_yml_path, "w") as f:
+ f.write('include: [".well-known"]\n')
+
+ for root, _, files in os.walk(docs_dir):
+ for file in files:
+ if not file.endswith(".md"):
+ continue
+
+ src_path = os.path.join(root, file)
+ rel_path = os.path.relpath(src_path, docs_dir).replace(os.sep, "/")
+ content = _get_materialized_content(rel_path, config)
+
+ if content:
+ clean_name = os.path.dirname(rel_path) + ".md"
+ dest_path = os.path.join(site_dir, clean_name)
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
+ with open(dest_path, "w", encoding="utf-8") as f:
+ f.write(content)
+ else:
+ # Check if this is a schema reference file that needs expansion
+ content = _get_schema_expanded_content(rel_path, config, src_path=src_path)
+ dest_path = os.path.join(site_dir, rel_path)
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
+ if content is not None:
+ # Write expanded schema content
+ log.info(f"Expanding schema references in {rel_path}")
+ with open(dest_path, "w", encoding="utf-8") as f:
+ f.write(content)
+ else:
+ # Just copy the file as-is
+ shutil.copy2(src_path, dest_path)
+
+ _write_well_known_skills(config, site_dir)
+ _generate_llms_files(config, site_dir)
+
+
+def _write_well_known_skills(config, site_dir):
+ """Parse skills/dstack/SKILL.md and write .well-known/skills/index.json. name and description come from frontmatter only."""
+ repo_root = os.path.dirname(config["config_file_path"])
+ skill_src = os.path.join(repo_root, *SKILL_PATH)
+ if not os.path.isfile(skill_src):
+ return
+
+ name = None
+ description = None
+ try:
+ with open(skill_src, "r", encoding="utf-8") as f:
+ text = f.read()
+ if text.startswith("---"):
+ parts = text.split("---", 2)
+ if len(parts) >= 3:
+ data = yaml.safe_load(parts[1])
+ if isinstance(data, dict):
+ name = data.get("name")
+ description = data.get("description")
+ except Exception as e:
+ log.error(f"Skill parsing error: {e}")
+
+ if not name or not description:
+ log.warning(
+ "skills/dstack/SKILL.md missing name or description in frontmatter; skipping .well-known/skills"
+ )
+ return
+
+ out_dir = os.path.join(site_dir, WELL_KNOWN_SKILLS_DIR, name)
+ os.makedirs(out_dir, exist_ok=True)
+ shutil.copy2(skill_src, os.path.join(out_dir, "SKILL.md"))
+ # Serve skill at site root (both skill.md and SKILL.md) from skills/dstack/SKILL.md
+ shutil.copy2(skill_src, os.path.join(site_dir, "skill.md"))
+ shutil.copy2(skill_src, os.path.join(site_dir, "SKILL.md"))
+
+ index_path = os.path.join(site_dir, WELL_KNOWN_SKILLS_DIR, "index.json")
+ index = {
+ "skills": [
+ {"name": name, "description": description.strip()[:1024], "files": ["SKILL.md"]}
+ ]
+ }
+ with open(index_path, "w", encoding="utf-8") as f:
+ json.dump(index, f, indent=2)
+
+ log.info(f"Published skill: {name}")
+
+
+def _generate_llms_files(config, site_dir):
+ """Generate llms.txt and llms-full.txt using external script."""
+ repo_root = os.path.dirname(config["config_file_path"])
+
+ # Import and run the generator
+ hooks_dir = os.path.dirname(os.path.abspath(__file__))
+ gen_path = os.path.join(hooks_dir, "gen_llms_files.py")
+ spec = importlib.util.spec_from_file_location("gen_llms_files", gen_path)
+ if spec is None or spec.loader is None:
+ log.error(f"Cannot load {gen_path}")
+ return
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["gen_llms_files"] = module
+ spec.loader.exec_module(module)
+
+ try:
+ # Pass mkdocs config to generator
+ module.generate_llms_files(repo_root, site_dir, config)
+ log.info("Generated llms.txt and llms-full.txt")
+ except Exception as e:
+ log.error(f"Failed to generate llms files: {e}")
diff --git a/skills/dstack/SKILL.md b/skills/dstack/SKILL.md
new file mode 100644
index 0000000000..e1d641c9a1
--- /dev/null
+++ b/skills/dstack/SKILL.md
@@ -0,0 +1,546 @@
+---
+name: dstack
+description: |
+ dstack is an open-source control plane for GPU provisioning and orchestration across GPU clouds, Kubernetes, and on-prem clusters.
+---
+
+# dstack
+
+## Overview
+
+`dstack` is a tool that allows to provision and orchestrate GPU workloads across [GPU clouds and Kubernetes clusters](https://dstack.ai/docs/concepts/backends.md) or on-prem clusters (SSH fleets).
+
+**When to use this skill:**
+- Running/managing GPU workloads (dev environments, tasks for training or other batch jobs, services to run inference or deploy web apps)
+- Creating, editing, and running `dstack` configurations
+- Managing fleets of compute (instances/clusters)
+
+## How it works
+
+`dstack` operates through three core components:
+
+1. `dstack` server - Can run locally, remotely, or via dstack Sky (managed)
+2. `dstack` CLI - For applying configurations and managing resources; CLI can be pointed to the server and a particular default project (`~/.dstack/config.yml` or via `dstack project` CLI command); other CLI commands use the default project
+3. `dstack` configuration files - YAML files ending with `.dstack.yml`
+
+**Typical workflow:**
+```bash
+# 1. Define configuration in YAML file (e.g., train.dstack.yml, .dstack.yml, llama-serve.dstack.yml)
+# 2. Apply configuration
+dstack apply -f train.dstack.yml
+
+# 3. dstack prepares a plan, and once confirmed, provisions instances (according to created fleets) and runs workloads
+# 4. Monitor with `dstack ps`, `dstack logs`, `dstack attach`, etc. (these commands support various options).
+```
+
+By default, `dstack apply` requires a confirmation, and once first job within the run is `running` - it "attaches" establishes an SSH tunnel, forwards ports if any and streams logs in real-time; if you pass `-d`, it runs in the detached mode and exits once the run is submitted.
+
+**CRITICAL: Never propose `dstack` CLI commands or YAML syntaxes that don't exist.**
+- Only use CLI commands and YAML syntax explicitly documented in this skill file or verified via `--help`
+- If uncertain about a command or its syntax, check the links or use `--help`
+
+**NEVER do the following:**
+- Invent CLI flags not documented here or shown in `--help`
+- Guess YAML property names - verify in configuration reference links
+- Run `dstack apply` for runs without `-d` in automated contexts (blocks indefinitely)
+- Retry failed commands without addressing the underlying error
+- Summarize or reformat tabular CLI output - show it as-is
+- Use `echo "y" |` when `-y` flag is available
+- Assume a command succeeded without checking output for errors
+
+## Agent execution guidelines
+
+**This section provides critical guidance for AI agents executing dstack commands.**
+
+### Output accuracy
+- **NEVER reformat, summarize, or paraphrase CLI output.** Display tables, status output, and error messages exactly as returned.
+- When showing command results, use code blocks to preserve formatting.
+- If output is truncated due to length, indicate this clearly (e.g., "Output truncated. Full output shows X entries.").
+
+### Verification before execution
+- **When uncertain about any CLI flag or YAML property, run `dstack --help` first.**
+- Never guess or invent flags. Example verification commands:
+ ```bash
+ dstack --help # List all commands
+ dstack apply --help # Flags for apply per configuration type (dev-environment, task, service, fleet, etc)
+ dstack fleet --help # Fleet subcommands
+ dstack ps --help # Flags for ps
+ ```
+- If a command or flag isn't documented, it doesn't exist.
+
+### Command timing and confirmation handling
+
+**Commands that run indefinitely (agents should avoid these):**
+- `dstack attach` - maintains connection until interrupted
+- `dstack apply` without `-d` for runs - streams logs after provisioning
+- `dstack ps -w` - watch mode, auto-refreshes until interrupted
+
+Instead, use `dstack ps -v` to check status, or `dstack apply -d` for detached mode.
+
+**All other commands:** Use 10-60s timeout. Most complete within this range. **While waiting, monitor the output** - it may contain errors, warnings, or prompts requiring attention.
+
+**Confirmation handling:**
+- `dstack apply`, `dstack stop`, `dstack fleet delete` require confirmation
+- Use `-y` flag to auto-confirm when user has already approved
+- Use `echo "n" |` to preview `dstack apply` plan without executing (avoid `echo "y" |`, prefer `-y`)
+
+**Best practices:**
+- Prefer modifying configuration files over passing parameters to `dstack apply` (unless it's an exception)
+- When user confirms deletion/stop operations, use `-y` flag to skip confirmation prompts
+- Avoid waiting indefinitely; display essential output once command is finished (even if by timeout)
+
+## Configuration types
+
+`dstack` supports five main configuration types, each with specific use cases. Configuration files can be named `.dstack.yml` or simply `.dstack.yml`.
+
+**Common parameters:** All run configurations (dev environments, tasks, services) support many parameters including:
+- **Git integration:** Clone repos automatically (`repo`), mount existing repos (`repos`), upload local files (`working_dir`)
+- **Docker support:** Use custom Docker images (`image`); Also if needed, use `docker: true` if you want to use `docker` from inside the container (VM-based backends only)
+- **Environment & secrets:** Set environment variables (`env`), reference secrets
+- **Storage:** Persistent network volumes (`volumes`), specify disk size
+- **Resources:** Define GPU, CPU, memory, and disk requirements
+
+**Best practices:**
+ - Prefer giving configurations a `name` property for easier management
+
+See configuration reference pages for complete parameter lists.
+
+### 1. Dev environments
+**Use for:** Interactive development with IDE integration (VS Code, Cursor, etc.).
+
+```yaml
+type: dev-environment
+name: cursor
+
+python: "3.12"
+ide: vscode
+
+resources:
+ gpu: 80GB
+ disk: 500GB
+```
+
+[Concept documentation](https://dstack.ai/docs/concepts/dev-environments.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/dev-environment.md)
+
+### 2. Tasks
+**Use for:** Batch jobs, training runs, fine-tuning, web applications, any executable workload.
+
+**Key features:** Distributed training (multi-node), port forwarding for web apps.
+
+```yaml
+type: task
+name: train
+
+python: "3.12"
+env:
+ - HUGGING_FACE_HUB_TOKEN
+commands:
+ - uv pip install -r requirements.txt
+ - uv run python train.py
+ports:
+ - 8501 # Optional: expose ports for web apps
+
+resources:
+ gpu: A100:40GB:2 # Two 40GB A100s
+ disk: 200GB
+```
+
+**Port forwarding:** When you specify `ports`, `dstack apply` automatically forwards them to `localhost` while attached. Use `dstack attach ` to reconnect and restore port forwarding. The run name becomes an SSH alias (e.g., `ssh `) for direct access.
+
+**Examples:**
+- [Single-node training (TRL)](https://dstack.ai/examples/single-node-training/trl/index.md)
+- [Single-node training (Axolotl)](https://dstack.ai/examples/single-node-training/axolotl/index.md)
+- [Distributed training (TRL)](https://dstack.ai/examples/distributed-training/trl/index.md)
+- [Distributed training (Axolotl)](https://dstack.ai/examples/distributed-training/axolotl/index.md)
+- [Distributed training (Ray+RAGEN)](https://dstack.ai/examples/distributed-training/ray-ragen/index.md)
+- [NCCL/RCCL tests](https://dstack.ai/examples/clusters/nccl-rccl-tests/index.md)
+
+[Concept documentation](https://dstack.ai/docs/concepts/tasks.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/task.md)
+
+### 3. Services
+**Use for:** Deploying models or web applications as production endpoints.
+
+**Key features:** OpenAI-compatible model serving, auto-scaling (RPS/queue), custom gateways with HTTPS.
+
+```yaml
+type: service
+name: llama31
+
+python: "3.12"
+env:
+ - HF_TOKEN
+commands:
+ - uv pip install vllm
+ - uv run vllm serve meta-llama/Meta-Llama-3.1-8B-Instruct
+port: 8000
+model: meta-llama/Meta-Llama-3.1-8B-Instruct
+
+resources:
+ gpu: 80GB
+ disk: 200GB
+
+```
+
+Once a service is `running` and its health probes are green:
+
+**Service endpoints:**
+- Without gateway: `/proxy/services///`
+- With gateway: `https://./`
+
+**Example:**
+```bash
+curl http://localhost:3000/proxy/services///v1/chat/completions \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer ' \
+ -d '{"model": "meta-llama/Meta-Llama-3.1-8B-Instruct", "messages": [{"role": "user", "content": "Hello!"}]}'
+```
+
+**Gateways:** Set up a [gateway](https://dstack.ai/docs/concepts/gateways.md) before running services to enable custom domains, HTTPS, auto-scaling rate limits, and production-grade endpoint management. Use the `dstack gateway` CLI command to manage gateways.
+
+**Examples:**
+- [SGLang](https://dstack.ai/examples/inference/sglang/index.md)
+- [vLLM](https://dstack.ai/examples/inference/vllm/index.md)
+- [NIM](https://dstack.ai/examples/inference/nim/index.md)
+- [TensorRT-LLM](https://dstack.ai/examples/inference/trtllm/index.md)
+
+[Concept documentation](https://dstack.ai/docs/concepts/services.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/service.md)
+
+### 4. Fleets
+**Use for:** Pre-provisioning infrastructure for workloads, managing on-premises GPU servers, creating auto-scaling instance pools.
+
+**Important:** Workloads (dev environments, tasks, services) only run if their resource requirements match at least one configured fleet. Without matching fleets, provisioning will fail.
+
+dstack supports two fleet types:
+
+#### Backend fleets (Cloud/Kubernetes)
+Dynamically provision instances from configured [backends](https://dstack.ai/docs/concepts/backends.md). Use the `nodes` property for on-demand scaling:
+
+```yaml
+type: fleet
+name: my-fleet
+nodes: 0..2 # Range: creates template when starting with 0, provisions on-demand
+
+resources:
+ gpu: 24GB.. # 24GB or more
+ disk: 200GB
+
+spot_policy: auto # auto (default), spot, or on-demand
+idle_duration: 5m # Terminate idle instances after 5 minutes
+```
+
+**On-demand provisioning:** When `nodes` is a range (e.g., `0..2`, `1..10`), dstack creates an instance template. Instances are provisioned automatically when workloads need them, scaling between min and max. Set `idle_duration` to terminate idle instances.
+
+**Additional options:** Fleets support many configuration options including `placement: cluster` for multi-node distributed workloads requiring inter-node communication (e.g., multi-GPU training), `blocks` for resource isolation, environment variables, and more. See the configuration reference for complete details.
+
+#### SSH fleets (on-prem or pre-provisioned clusters)
+Use existing GPU servers accessible via SSH:
+
+```yaml
+type: fleet
+name: on-prem-fleet
+
+ssh_config:
+ user: ubuntu
+ identity_file: ~/.ssh/id_rsa
+ hosts:
+ - 192.168.1.10
+ - 192.168.1.11
+```
+
+[Concept documentation](https://dstack.ai/docs/concepts/fleets.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/fleet.md)
+
+### 5. Volumes
+**Use for:** Persistent storage for datasets, model checkpoints, training artifacts that persist across runs and can be shared between workloads.
+
+dstack supports two types of volumes:
+
+#### Network Volumes
+Backend-specific persistent volumes (AWS EBS, GCP Persistent Disk, etc.) that can be attached to any dev environment, task, or service.
+
+**Define a network volume:**
+```yaml
+type: volume
+name: my-volume
+
+backend: aws
+region: us-east-1
+
+resources:
+ disk: 500GB
+```
+
+**Attach to workloads via `volumes` property:**
+```yaml
+type: task
+# ... other config
+volumes:
+ - name: my-volume
+ path: /volume_data
+```
+
+#### Instance Volumes
+Faster local volumes using the instance's root disk. Ideal for ephemeral storage, caching, or maximum I/O performance without persistence across instances.
+
+**Attach instance volumes via `volumes` property:**
+```yaml
+type: dev-environment
+# ... other config
+volumes:
+ - name: my-instance-volume
+ path: /cache_data
+```
+
+**Note:** Volumes can be attached to dev environments, tasks, and services using the `volumes` property. Network volumes persist independently, while instance volumes are tied to the instance lifecycle.
+
+[Concept documentation](https://dstack.ai/docs/concepts/volumes.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/volume.md)
+
+## Essential CLI commands
+
+### Apply configurations
+
+**Important behavior:**
+- `dstack apply` shows a plan with estimated costs and may ask for confirmation (respond with `y` or use `-y` flag to skip)
+- Once confirmed, it provisions infrastructure and streams real-time output to the terminal
+- In attached mode (default), the terminal blocks and shows output - use timeout or Ctrl+C to interrupt if you need to continue with other commands
+- In detached mode (`-d`), runs in background without blocking the terminal
+
+**Workflow for applying configurations:**
+
+> **Critical for agents:** Always show the plan first, wait for user confirmation, THEN execute. Never auto-execute without user approval.
+
+**Step-by-step for run configurations (dev-environment, task, service):**
+
+1. **Show plan:**
+ ```bash
+ echo "n" | dstack apply -f config.dstack.yml
+ ```
+ Display the FULL output including the offers table and cost estimate. **Do NOT summarize or reformat.**
+
+2. **Wait for user confirmation.** Do NOT proceed if:
+ - Output shows "No offers found" or similar errors
+ - Output shows validation errors
+ - User has not explicitly confirmed
+
+3. **Execute (only after user confirms):**
+ ```bash
+ dstack apply -f config.dstack.yml -y -d
+ ```
+
+4. **Verify apply status:**
+ ```bash
+ dstack ps -v
+ ```
+ Show the run status. Look for the run name and status column.
+
+**Step-by-step for infrastructure (fleet, volume, gateway):**
+
+1. **Show plan:**
+ ```bash
+ echo "n" | dstack apply -f fleet.dstack.yml
+ ```
+ Display the FULL output. **Do NOT summarize or reformat.**
+
+2. **Wait for user confirmation.**
+
+3. **Execute:**
+ ```bash
+ dstack apply -f fleet.dstack.yml -y
+ ```
+
+4. **Verify:** Use `dstack fleet`, `dstack volume`, or `dstack gateway` respectively.
+
+**Common apply patterns:**
+```bash
+# Apply and attach (interactive, blocks terminal with port forwarding)
+dstack apply -f train.dstack.yml
+
+# Apply with automatic confirmation
+dstack apply -f train.dstack.yml -y
+
+# Apply detached (background, no attachment)
+dstack apply -f serve.dstack.yml -d
+
+# Force rerun (recreates even if run with same name exists)
+dstack apply -f finetune.dstack.yml --force
+
+# Override defaults (prefer modifying config file instead, unless it's an exception)
+dstack apply -f .dstack.yml --max-price 2.5
+```
+
+### Fleet Management
+
+```bash
+# Create/update fleet
+dstack apply -f fleet.dstack.yml
+
+# List fleets
+dstack fleet
+
+# Get fleet details
+dstack fleet get my-fleet
+
+# Get fleet details as JSON (for troubleshooting)
+dstack fleet get my-fleet --json
+
+# Delete entire fleet (use -y when user already confirmed)
+dstack fleet delete my-fleet -y
+
+# IMPORTANT: When asked to delete an instance, always use -i - do NOT delete the entire fleet (use -y when user already confirmed)
+dstack fleet delete my-fleet -i -y
+```
+
+### Monitor runs
+
+```bash
+# List all runs
+dstack ps
+
+# JSON output (for troubleshooting/scripting)
+dstack ps --json
+
+# Verbose output with full details
+dstack ps -v
+
+# Get specific run details as JSON
+dstack run get my-run-name --json
+```
+
+### Attach to runs
+
+**What is attaching?** Attaching connects to an existing run to restore port forwarding (for tasks/services with ports) and enable SSH access. The run name becomes an SSH alias (e.g., `ssh my-run-name`) configured in `~/.dstack/ssh/config` (included to `~/.ssh/config`).
+
+**Note:** `dstack apply` automatically attaches when run completes provisioning. Use `dstack attach` to reconnect after detaching or to access detached runs.
+
+```bash
+# Attach and replay logs from start (preferred, unless asked otherwise)
+dstack attach my-run-name --logs
+
+# Attach without replaying logs (restores port forwarding + SSH only)
+dstack attach my-run-name
+```
+
+### View logs
+
+```bash
+# Stream logs (tail mode)
+dstack logs my-run-name
+
+# Debug mode (includes additional runner logs)
+dstack logs my-run-name -d
+
+# Fetch logs from specific replica (multi-node runs)
+dstack logs my-run-name --replica 1
+
+# Fetch logs from specific job
+dstack logs my-run-name --job 0
+```
+
+### Stop runs
+
+```bash
+# Stop specific run
+dstack stop my-run-name
+
+# Stop with confirmation skipped (use when user already confirmed)
+dstack stop my-run-name -y
+
+# Abort (force stop)
+dstack stop my-run-name --abort
+```
+
+### Check available resources
+
+**Use `dstack offer` to verify GPU availability before provisioning:**
+
+```bash
+# List all available offers across backends
+dstack offer --json
+
+# Filter by specific backend
+dstack offer --backend aws
+
+# Filter by GPU type
+dstack offer --gpu A100
+
+# Filter by GPU memory
+dstack offer --gpu 24GB..80GB
+
+# JSON output for detailed inspection
+dstack offer --json
+
+# Combine filters
+dstack offer --backend aws --gpu A100:80GB
+```
+
+**Note:** `dstack offer` shows all available GPU instances from configured backends, not just those matching configured fleets. Use it to check backend availability, but remember: an offer appearing here doesn't guarantee a fleet will provision it - fleets have their own resource constraints.
+
+### Expected Output Formats
+
+**Agents should display these tables as-is, preserving column alignment.**
+
+## Troubleshooting
+
+When diagnosing issues with dstack workloads or infrastructure:
+
+1. **Use JSON output for detailed inspection:**
+ ```bash
+ dstack fleet get my-fleet --json | jq .
+ dstack run get my-run --json | jq .
+ dstack ps -n 10 --json | jq .
+ ```
+
+2. **Check verbose run status:**
+ ```bash
+ dstack ps -v # Shows provisioning state, instance details, errors
+ ```
+
+3. **Examine logs with debug output:**
+ ```bash
+ dstack logs my-run -d # Includes additional runner logs
+ ```
+
+4. **Attach with log replay:**
+ ```bash
+ dstack attach my-run --logs # See full output from start
+ ```
+
+5. **Verify resource availability:**
+ ```bash
+ dstack offer --backend aws --gpu A100 --spot-auto --json # Check if resources exist
+ ```
+
+Common issues:
+- **No offers:** Check `dstack offer` and ensure that at least one fleet matches requirements
+- **No fleet:** Ensure at least one fleet is created
+- **Configuration errors:** Validate YAML syntax; check `dstack apply` output for specific errors
+- **Provisioning timeouts:** Use `dstack ps -v` to see provisioning status; consider spot vs on-demand
+- **Connection issues:** Verify server status, check authentication, ensure network access to backends
+
+**When errors occur:**
+1. Display the full error message unchanged
+2. Do NOT retry the same command without addressing the error
+3. Refer to the [Troubleshooting guide](https://dstack.ai/docs/guides/troubleshooting.md) for guidance
+
+## Additional Resources
+
+**Core documentation:**
+- [Overview](https://dstack.ai/docs/overview.md)
+- [Installation](https://dstack.ai/docs/installation.md)
+- [Quickstart](https://dstack.ai/docs/quickstart.md)
+
+**Additional concepts:**
+- [Secrets](https://dstack.ai/docs/concepts/secrets.md) - Manage sensitive credentials
+- [Projects](https://dstack.ai/docs/concepts/projects.md) - Projects isolate the resources of different teams
+- [Metrics](https://dstack.ai/docs/concepts/metrics.md) - Track GPU utilization
+- [Events](https://dstack.ai/docs/concepts/events.md) - Monitor system events
+
+**Guides:**
+- [Server deployment](https://dstack.ai/docs/guides/server-deployment.md) (for server administration)
+- [Pro tips](https://dstack.ai/docs/guides/protips.md)
+
+**Accelerator-specific examples:**
+- [AMD](https://dstack.ai/examples/accelerators/amd/index.md)
+- [Google TPU](https://dstack.ai/examples/accelerators/tpu/index.md)
+- [Tenstorrent](https://dstack.ai/examples/accelerators/tenstorrent/index.md)
+
+**Full documentation:** https://dstack.ai/llms-full.txt
From 14ef341ce31391918ec02454799c12b7998a3784 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Sat, 31 Jan 2026 22:55:04 +0100
Subject: [PATCH 045/640] [Docs] Remove the mention of the gateway endpoint
#3514 (#3518)
* [Docs] Remove the mention of the gateway endpoint #3514
* Update docs/docs/concepts/services.md
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* Update docs/docs/concepts/services.md
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* [Docs] Updated examples (migrated from gateway endpoint to service endpoint)
---------
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
---
docs/blog/posts/dstack-sky.md | 5 +-
docs/docs/concepts/services.md | 72 ++++++++++++---------
examples/accelerators/tenstorrent/README.md | 2 +-
examples/inference/nim/README.md | 8 +--
examples/inference/sglang/README.md | 13 ++--
examples/inference/tgi/README.md | 8 +--
examples/inference/trtllm/README.md | 8 +--
examples/inference/vllm/README.md | 8 +--
examples/llms/deepseek/README.md | 14 ++--
examples/llms/llama/README.md | 2 +-
examples/llms/llama31/README.md | 8 +--
11 files changed, 73 insertions(+), 75 deletions(-)
diff --git a/docs/blog/posts/dstack-sky.md b/docs/blog/posts/dstack-sky.md
index 6919372024..877befb044 100644
--- a/docs/blog/posts/dstack-sky.md
+++ b/docs/blog/posts/dstack-sky.md
@@ -121,15 +121,14 @@ model: mixtral
```
-If it has a `model` mapping, the model will be accessible
-at `https://gateway..sky.dstack.ai` via the OpenAI compatible interface.
+The service endpoint will be accessible at `https://..sky.dstack.ai` via the OpenAI compatible interface.
```python
from openai import OpenAI
client = OpenAI(
- base_url="https://gateway..sky.dstack.ai",
+ base_url="https://..sky.dstack.ai/v1",
api_key=""
)
diff --git a/docs/docs/concepts/services.md b/docs/docs/concepts/services.md
index fe5830f356..fed9f7cb39 100644
--- a/docs/docs/concepts/services.md
+++ b/docs/docs/concepts/services.md
@@ -73,7 +73,7 @@ Model meta-llama/Meta-Llama-3.1-8B-Instruct is published at:
`dstack apply` automatically provisions instances and runs the service.
-If a [gateway](gateways.md) is not configured, the service’s endpoint will be accessible at
+If you do not have a [gateway](gateways.md) created, the service endpoint will be accessible at
`/proxy/services///`.
@@ -95,37 +95,50 @@ $ curl http://localhost:3000/proxy/services/main/llama31/v1/chat/completions \
-If the service defines the [`model`](#model) property, the model can be accessed with
-the global OpenAI-compatible endpoint at `/proxy/models//`,
-or via `dstack` UI.
+If [authorization](#authorization) is not disabled, the service endpoint requires the `Authorization` header with `Bearer `.
-If [authorization](#authorization) is not disabled, the service endpoint requires the `Authorization` header with
-`Bearer `.
+## Configuration options
-??? info "Gateway"
- Running services for development purposes doesn’t require setting up a [gateway](gateways.md).
+
- However, you'll need a gateway in the following cases:
+### Gateway
- * To use auto-scaling or rate limits
- * To enable a support custom router, e.g. such as the [SGLang Model Gateway](https://docs.sglang.ai/advanced_features/router.html#)
- * To enable HTTPS for the endpoint and map it to your domain
- * If your service requires WebSockets
- * If your service cannot work with a [path prefix](#path-prefix)
+Here are cases where a service may need a [gateway](gateways.md):
-
+* To use [auto-scaling](#replicas-and-scaling) or [rate limits](#rate-limits)
+* To enable a support custom router, e.g. such as the [SGLang Model Gateway](https://docs.sglang.ai/advanced_features/router.html#)
+* To enable HTTPS for the endpoint and map it to your domain
+* If your service requires WebSockets
+* If your service cannot work with a [path prefix](#path-prefix)
- If a [gateway](gateways.md) is configured, the service endpoint will be accessible at
- `https://./`.
+
- If the service defines the `model` property, the model will be available via the global OpenAI-compatible endpoint
- at `https://gateway./`.
+If you want `dstack` to explicitly validate that a gateway is used, you can set the [`gateway`](../reference/dstack.yml/service.md#gateway) property in the service configuration to `true`. In this case, `dstack` will raise an error during `dstack apply` if a default gateway is not created.
-## Configuration options
+You can also set the `gateway` property to the name of a specific gateway, if required.
+
+If you have a [gateway](gateways.md) created, the service endpoint will be accessible at `https://./`:
+
+
+
+```shell
+$ curl https://llama31.example.com/v1/chat/completions \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Bearer <dstack token>' \
+ -d '{
+ "model": "meta-llama/Meta-Llama-3.1-8B-Instruct",
+ "messages": [
+ {
+ "role": "user",
+ "content": "Compose a poem that explains the concept of recursion in programming."
+ }
+ ]
+ }'
+```
-!!! info "No commands"
- If `commands` are not specified, `dstack` runs `image`’s entrypoint (or fails if none is set).
+
### Replicas and scaling
@@ -220,12 +233,6 @@ Setting the minimum number of replicas to `0` allows the service to scale down t
??? info "Disaggregated serving"
Native support for disaggregated prefill and decode, allowing both worker types to run within a single service, is coming soon.
-### Model
-
-If the service is running a chat model with an OpenAI-compatible interface,
-set the [`model`](#model) property to make the model accessible via `dstack`'s
-global OpenAI-compatible endpoint, and also accessible via `dstack`'s UI.
-
### Authorization
By default, the service enables authorization, meaning the service endpoint requires a `dstack` user token.
@@ -364,7 +371,7 @@ set [`strip_prefix`](../reference/dstack.yml/service.md#strip_prefix) to `false`
If your app cannot be configured to work with a path prefix, you can host it
on a dedicated domain name by setting up a [gateway](gateways.md).
-### Rate limits { #rate-limits }
+### Rate limits
If you have a [gateway](gateways.md), you can configure rate limits for your service
using the [`rate_limits`](../reference/dstack.yml/service.md#rate_limits) property.
@@ -413,6 +420,11 @@ Limits apply to the whole service (all replicas) and per client (by IP). Clients
+### Model
+
+If the service runs a model with an OpenAI-compatible interface, you can set the [`model`](#model) property to make the model accessible through `dstack`'s chat UI on the `Models` page.
+In this case, `dstack` will use the service's `/v1/chat/completions` service.
+
### Resources
If you specify memory size, you can either specify an explicit size (e.g. `24GB`) or a
diff --git a/examples/accelerators/tenstorrent/README.md b/examples/accelerators/tenstorrent/README.md
index 95f3f2b6c9..4edc463f67 100644
--- a/examples/accelerators/tenstorrent/README.md
+++ b/examples/accelerators/tenstorrent/README.md
@@ -96,7 +96,7 @@ at `/proxy/services///`.
```shell
-$ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+$ curl http://127.0.0.1:3000/proxy/services/main/tt-inference-server/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
diff --git a/examples/inference/nim/README.md b/examples/inference/nim/README.md
index ec323beb94..356492a49a 100644
--- a/examples/inference/nim/README.md
+++ b/examples/inference/nim/README.md
@@ -78,13 +78,12 @@ Provisioning...
```
-If no gateway is created, the model will be available via the OpenAI-compatible endpoint
-at `/proxy/models//`.
+If no gateway is created, the service endpoint will be available at `/proxy/services///`.
```shell
-$ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+$ curl http://127.0.0.1:3000/proxy/services/main/serve-distill-deepseek/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
@@ -106,8 +105,7 @@ $ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
-When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the OpenAI-compatible endpoint
-is available at `https://gateway./`.
+When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the service endpoint will be available at `https://serve-distill-deepseek./`.
## Source code
diff --git a/examples/inference/sglang/README.md b/examples/inference/sglang/README.md
index bc95adc042..5b7dc640a0 100644
--- a/examples/inference/sglang/README.md
+++ b/examples/inference/sglang/README.md
@@ -17,7 +17,7 @@ Here's an example of a service that deploys DeepSeek-R1-Distill-Llama 8B and 70B
```yaml
type: service
- name: deepseek-r1-nvidia
+ name: deepseek-r1
image: lmsysorg/sglang:latest
env:
@@ -43,7 +43,7 @@ Here's an example of a service that deploys DeepSeek-R1-Distill-Llama 8B and 70B
```yaml
type: service
- name: deepseek-r1-amd
+ name: deepseek-r1
image: lmsysorg/sglang:v0.4.1.post4-rocm620
env:
@@ -74,20 +74,19 @@ $ dstack apply -f examples/llms/deepseek/sglang/amd/.dstack.yml
# BACKEND REGION RESOURCES SPOT PRICE
1 runpod EU-RO-1 24xCPU, 283GB, 1xMI300X (192GB) no $2.49
-Submit the run deepseek-r1-amd? [y/n]: y
+Submit the run deepseek-r1? [y/n]: y
Provisioning...
---> 100%
```
-Once the service is up, the model will be available via the OpenAI-compatible endpoint
-at `/proxy/models//`.
+If no gateway is created, the service endpoint will be available at `/proxy/services///`.
```shell
-curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+curl http://127.0.0.1:3000/proxy/services/main/deepseek-r1/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
@@ -112,7 +111,7 @@ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
!!! info "SGLang Model Gateway"
If you'd like to use a custom routing policy, e.g. by leveraging the [SGLang Model Gateway](https://docs.sglang.ai/advanced_features/router.html#), create a gateway with `router` set to `sglang`. Check out [gateways](https://dstack.ai/docs/concepts/gateways#router) for more details.
-> If a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured (e.g. to enable auto-scaling or HTTPs, rate-limits, etc), the OpenAI-compatible endpoint is available at `https://gateway./`.
+> If a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured (e.g. to enable auto-scaling or HTTPs, rate-limits, etc), the service endpoint will be available at `https://deepseek-r1./`.
## Source code
diff --git a/examples/inference/tgi/README.md b/examples/inference/tgi/README.md
index 971929bf61..08a1de74db 100644
--- a/examples/inference/tgi/README.md
+++ b/examples/inference/tgi/README.md
@@ -82,13 +82,12 @@ Provisioning...
```
-If no gateway is created, the model will be available via the OpenAI-compatible endpoint
-at `/proxy/models//`.
+If no gateway is created, the service endpoint will be available at `/proxy/services///`.
```shell
-$ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+$ curl http://127.0.0.1:3000/proxy/services/main/llama4-scout/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
@@ -110,8 +109,7 @@ $ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
-When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the OpenAI-compatible endpoint
-is available at `https://gateway./`.
+When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the service endpoint will be available at `https://llama4-scout./`.
## Source code
diff --git a/examples/inference/trtllm/README.md b/examples/inference/trtllm/README.md
index 58f0d97ebb..31ddb5b0c3 100644
--- a/examples/inference/trtllm/README.md
+++ b/examples/inference/trtllm/README.md
@@ -330,13 +330,12 @@ Provisioning...
## Access the endpoint
-If no gateway is created, the model will be available via the OpenAI-compatible endpoint
-at `/proxy/models//`.
+If no gateway is created, the service endpoint will be available at `/proxy/services///`.
```shell
-$ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+$ curl http://127.0.0.1:3000/proxy/services/main/serve-distill/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
@@ -359,8 +358,7 @@ $ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
-When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the OpenAI-compatible endpoint
-is available at `https://gateway./`.
+When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the service endpoint will be available at `https://serve-distill./`.
## Source code
diff --git a/examples/inference/vllm/README.md b/examples/inference/vllm/README.md
index c5a635b3a4..7af4e97989 100644
--- a/examples/inference/vllm/README.md
+++ b/examples/inference/vllm/README.md
@@ -79,13 +79,12 @@ Provisioning...
```
-If no gateway is created, the model will be available via the OpenAI-compatible endpoint
-at `/proxy/models//`.
+If no gateway is created, the service endpoint will be available at `/proxy/services///`.
```shell
-$ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+$ curl http://127.0.0.1:3000/proxy/services/main/llama31/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
@@ -107,8 +106,7 @@ $ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
-When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the OpenAI-compatible endpoint
-is available at `https://gateway./`.
+When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the service endpoint will be available at `https://llama31./`.
## Source code
diff --git a/examples/llms/deepseek/README.md b/examples/llms/deepseek/README.md
index bca90ac35c..41d73e9e99 100644
--- a/examples/llms/deepseek/README.md
+++ b/examples/llms/deepseek/README.md
@@ -179,7 +179,7 @@ Both SGLang and vLLM also support `Deepseek-V2-Lite`.
```yaml
type: service
- name: deepseek-r1-nvidia
+ name: deepseek-r1
image: lmsysorg/sglang:latest
env:
@@ -203,7 +203,7 @@ Both SGLang and vLLM also support `Deepseek-V2-Lite`.
```yaml
type: service
- name: deepseek-r1-nvidia
+ name: deepseek-r1
image: vllm/vllm-openai:latest
env:
@@ -255,20 +255,19 @@ $ dstack apply -f examples/llms/deepseek/sglang/amd/.dstack.yml
# BACKEND REGION RESOURCES SPOT PRICE
1 runpod EU-RO-1 24xCPU, 283GB, 1xMI300X (192GB) no $2.49
-Submit the run deepseek-r1-amd? [y/n]: y
+Submit the run deepseek-r1? [y/n]: y
Provisioning...
---> 100%
```
-Once the service is up, the model will be available via the OpenAI-compatible endpoint
-at `/proxy/models//`.
+If no gateway is created, the service endpoint will be available at `/proxy/services///`.
```shell
-curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+curl http://127.0.0.1:3000/proxy/services/main/deepseek-r1/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
@@ -290,8 +289,7 @@ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
```
-When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the OpenAI-compatible endpoint
-is available at `https://gateway./`.
+When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the service endpoint will be available at `https://deepseek-r1./`.
## Fine-tuning
diff --git a/examples/llms/llama/README.md b/examples/llms/llama/README.md
index 573d4b8037..3f2b8ab54d 100644
--- a/examples/llms/llama/README.md
+++ b/examples/llms/llama/README.md
@@ -171,7 +171,7 @@ at `/proxy/services///`.
```shell
-curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+curl http://127.0.0.1:3000/proxy/services/main/llama4-scout/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
diff --git a/examples/llms/llama31/README.md b/examples/llms/llama31/README.md
index ff288e3c87..b99362cde0 100644
--- a/examples/llms/llama31/README.md
+++ b/examples/llms/llama31/README.md
@@ -179,13 +179,12 @@ Provisioning...
-Once the service is up, the model will be available via the OpenAI-compatible endpoint
-at `/proxy/models//`.
+If no gateway is created, the service endpoint will be available at `/proxy/services///`.
```shell
-$ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
+$ curl http://127.0.0.1:3000/proxy/services/main/llama31/v1/chat/completions \
-X POST \
-H 'Authorization: Bearer <dstack token>' \
-H 'Content-Type: application/json' \
@@ -207,8 +206,7 @@ $ curl http://127.0.0.1:3000/proxy/models/main/chat/completions \
-When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the OpenAI-compatible endpoint
-is available at `https://gateway./`.
+When a [gateway](https://dstack.ai/docs/concepts/gateways/) is configured, the service endpoint will be available at `https://llama31./`.
[//]: # (TODO: How to prompting and tool calling)
From c46205bc39b8b46085cde4d480ee2361bfc2bc82 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Mon, 2 Feb 2026 08:56:10 +0000
Subject: [PATCH 046/640] Add service and replica registration events (#3516)
- Service registered in gateway
- Service unregistered from gateway
- Service replica registered to receive requests
- Service replica unregistered from receiving
requests
---
.../server/services/gateways/__init__.py | 4 +-
.../server/services/services/__init__.py | 63 ++++++++++++++-----
.../tasks/test_process_running_jobs.py | 14 ++++-
.../_internal/server/routers/test_runs.py | 12 +++-
4 files changed, 73 insertions(+), 20 deletions(-)
diff --git a/src/dstack/_internal/server/services/gateways/__init__.py b/src/dstack/_internal/server/services/gateways/__init__.py
index bff20466a8..ab89c2a7c8 100644
--- a/src/dstack/_internal/server/services/gateways/__init__.py
+++ b/src/dstack/_internal/server/services/gateways/__init__.py
@@ -444,7 +444,7 @@ async def generate_gateway_name(session: AsyncSession, project: ProjectModel) ->
async def get_or_add_gateway_connection(
session: AsyncSession, gateway_id: uuid.UUID
-) -> GatewayConnection:
+) -> tuple[GatewayModel, GatewayConnection]:
gateway = await session.get(GatewayModel, gateway_id)
if gateway is None:
raise GatewayError("Gateway not found")
@@ -460,7 +460,7 @@ async def get_or_add_gateway_connection(
"Failed to connect to gateway %s: %s", gateway.gateway_compute.ip_address, e
)
raise GatewayError("Failed to connect to gateway")
- return conn
+ return gateway, conn
async def init_gateways(session: AsyncSession):
diff --git a/src/dstack/_internal/server/services/services/__init__.py b/src/dstack/_internal/server/services/services/__init__.py
index 45bb1fe0fe..06aa5b0ef0 100644
--- a/src/dstack/_internal/server/services/services/__init__.py
+++ b/src/dstack/_internal/server/services/services/__init__.py
@@ -29,6 +29,7 @@
from dstack._internal.core.models.runs import JobSpec, Run, RunSpec, ServiceModelSpec, ServiceSpec
from dstack._internal.server import settings
from dstack._internal.server.models import GatewayModel, JobModel, ProjectModel, RunModel
+from dstack._internal.server.services import events
from dstack._internal.server.services.gateways import (
get_gateway_configuration,
get_or_add_gateway_connection,
@@ -114,7 +115,7 @@ async def _register_service_in_gateway(
domain = service_spec.get_domain()
assert domain is not None
- conn = await get_or_add_gateway_connection(session, gateway.id)
+ _, conn = await get_or_add_gateway_connection(session, gateway.id)
try:
logger.debug("%s: registering service as %s", fmt(run_model), service_spec.url)
async with conn.client() as client:
@@ -131,13 +132,21 @@ async def _register_service_in_gateway(
ssh_private_key=run_model.project.ssh_private_key,
router=router,
)
- logger.info("%s: service is registered as %s", fmt(run_model), service_spec.url)
except SSHError:
raise ServerClientError("Gateway tunnel is not working")
except httpx.RequestError as e:
logger.debug("Gateway request failed", exc_info=True)
raise GatewayError(f"Gateway is not working: {e!r}")
+ events.emit(
+ session,
+ "Service registered in gateway",
+ actor=events.SystemActor(),
+ targets=[
+ events.Target.from_model(run_model),
+ events.Target.from_model(gateway),
+ ],
+ )
return service_spec
@@ -193,8 +202,9 @@ async def register_replica(
ssh_head_proxy: Optional[SSHConnectionParams],
ssh_head_proxy_private_key: Optional[str],
):
+ gateway = None
if gateway_id is not None:
- conn = await get_or_add_gateway_connection(session, gateway_id)
+ gateway, conn = await get_or_add_gateway_connection(session, gateway_id)
job_submission = jobs_services.job_model_to_job_submission(job_model)
try:
logger.debug("%s: registering replica for service %s", fmt(job_model), run.id.hex)
@@ -225,17 +235,21 @@ async def register_replica(
else:
raise
job_model.registered = True
- logger.info(
- "%s: service replica registered to receive requests, gateway=%s",
- fmt(job_model),
- gateway_id is not None,
+ targets = [events.Target.from_model(job_model)]
+ if gateway is not None:
+ targets.append(events.Target.from_model(gateway))
+ events.emit(
+ session,
+ "Service replica registered to receive requests",
+ actor=events.SystemActor(),
+ targets=targets,
)
async def unregister_service(session: AsyncSession, run_model: RunModel):
if run_model.gateway_id is None: # in-server proxy
return
- conn = await get_or_add_gateway_connection(session, run_model.gateway_id)
+ gateway, conn = await get_or_add_gateway_connection(session, run_model.gateway_id)
res = await session.execute(
select(ProjectModel).where(ProjectModel.id == run_model.project_id)
)
@@ -247,24 +261,37 @@ async def unregister_service(session: AsyncSession, run_model: RunModel):
project=project.name,
run_name=run_model.run_name,
)
- logger.debug("%s: service is unregistered", fmt(run_model))
+ event_msg = "Service unregistered from gateway"
except GatewayError as e:
# ignore if service is not registered
logger.warning("%s: unregistering service: %s", fmt(run_model), e)
+ event_msg = f"Gateway error when unregistering service: {e}"
except (httpx.RequestError, SSHError) as e:
logger.debug("Gateway request failed", exc_info=True)
raise GatewayError(repr(e))
+ events.emit(
+ session,
+ event_msg,
+ actor=events.SystemActor(),
+ targets=[
+ events.Target.from_model(run_model),
+ events.Target.from_model(gateway),
+ ],
+ )
async def unregister_replica(session: AsyncSession, job_model: JobModel):
+ if not job_model.registered: # non-services and unregistered service replicas
+ return
res = await session.execute(
select(RunModel)
.where(RunModel.id == job_model.run_id)
- .options(joinedload(RunModel.project).joinedload(ProjectModel.backends))
+ .options(joinedload(RunModel.project))
)
run_model = res.unique().scalar_one()
+ gateway = None
if run_model.gateway_id is not None:
- conn = await get_or_add_gateway_connection(session, run_model.gateway_id)
+ gateway, conn = await get_or_add_gateway_connection(session, run_model.gateway_id)
try:
logger.debug(
"%s: unregistering replica from service %s", fmt(job_model), job_model.run_id.hex
@@ -282,10 +309,14 @@ async def unregister_replica(session: AsyncSession, job_model: JobModel):
logger.debug("Gateway request failed", exc_info=True)
raise GatewayError(repr(e))
job_model.registered = False
- logger.info(
- "%s: service replica unregistered from receiving requests, gateway=%s",
- fmt(job_model),
- run_model.gateway_id is not None,
+ targets = [events.Target.from_model(job_model)]
+ if gateway is not None:
+ targets.append(events.Target.from_model(gateway))
+ events.emit(
+ session,
+ "Service replica unregistered from receiving requests",
+ actor=events.SystemActor(),
+ targets=targets,
)
@@ -314,7 +345,7 @@ async def update_service_desired_replica_count(
) -> None:
stats = None
if run_model.gateway_id is not None:
- conn = await get_or_add_gateway_connection(session, run_model.gateway_id)
+ _, conn = await get_or_add_gateway_connection(session, run_model.gateway_id)
stats = await conn.get_stats(run_model.project.name, run_model.run_name)
replica_groups = configuration.replica_groups
desired_replica_counts = {}
diff --git a/src/tests/_internal/server/background/tasks/test_process_running_jobs.py b/src/tests/_internal/server/background/tasks/test_process_running_jobs.py
index 9e318866c5..12edeec208 100644
--- a/src/tests/_internal/server/background/tasks/test_process_running_jobs.py
+++ b/src/tests/_internal/server/background/tasks/test_process_running_jobs.py
@@ -1014,6 +1014,11 @@ async def test_registers_service_replica_immediately_if_no_probes(
await session.refresh(job)
assert job.status == JobStatus.RUNNING
assert job.registered
+ events = await list_events(session)
+ assert {e.message for e in events} == {
+ "Job status changed PULLING -> RUNNING",
+ "Service replica registered to receive requests",
+ }
@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
@@ -1104,7 +1109,14 @@ async def test_registers_service_replica_only_after_probes_pass(
await process_running_jobs()
await session.refresh(job)
- assert job.registered == expect_to_register
+ events = await list_events(session)
+ if expect_to_register:
+ assert job.registered
+ assert len(events) == 1
+ assert events[0].message == "Service replica registered to receive requests"
+ else:
+ assert not job.registered
+ assert not events
class TestPatchBaseImageForAwsEfa:
diff --git a/src/tests/_internal/server/routers/test_runs.py b/src/tests/_internal/server/routers/test_runs.py
index 70ab54bd16..d24382f35e 100644
--- a/src/tests/_internal/server/routers/test_runs.py
+++ b/src/tests/_internal/server/routers/test_runs.py
@@ -63,6 +63,7 @@
get_fleet_spec,
get_job_provisioning_data,
get_run_spec,
+ list_events,
)
from dstack._internal.server.testing.matchers import SomeUUID4Str
from tests._internal.server.background.tasks.test_process_running_jobs import settings
@@ -2085,6 +2086,7 @@ def mock_gateway_connections(self) -> Generator[None, None, None]:
"specified_gateway_in_run_conf",
"expected_service_url",
"expected_model_url",
+ "is_gateway",
),
[
pytest.param(
@@ -2092,6 +2094,7 @@ def mock_gateway_connections(self) -> Generator[None, None, None]:
None,
"https://test-service.default-gateway.example",
"https://gateway.default-gateway.example",
+ True,
id="submits-to-default-gateway",
),
pytest.param(
@@ -2099,6 +2102,7 @@ def mock_gateway_connections(self) -> Generator[None, None, None]:
True,
"https://test-service.default-gateway.example",
"https://gateway.default-gateway.example",
+ True,
id="submits-to-default-gateway-when-gateway-true",
),
pytest.param(
@@ -2106,6 +2110,7 @@ def mock_gateway_connections(self) -> Generator[None, None, None]:
"non-default-gateway",
"https://test-service.non-default-gateway.example",
"https://gateway.non-default-gateway.example",
+ True,
id="submits-to-specified-gateway",
),
pytest.param(
@@ -2113,6 +2118,7 @@ def mock_gateway_connections(self) -> Generator[None, None, None]:
None,
"/proxy/services/test-project/test-service/",
"/proxy/models/test-project/",
+ False,
id="submits-in-server-when-no-default-gateway",
),
pytest.param(
@@ -2120,6 +2126,7 @@ def mock_gateway_connections(self) -> Generator[None, None, None]:
False,
"/proxy/services/test-project/test-service/",
"/proxy/models/test-project/",
+ False,
id="submits-in-server-when-specified",
),
],
@@ -2130,9 +2137,10 @@ async def test_submit_to_correct_proxy(
session: AsyncSession,
client: AsyncClient,
existing_gateways: List[Tuple[str, bool]],
- specified_gateway_in_run_conf: str,
+ specified_gateway_in_run_conf: Union[str, bool, None],
expected_service_url: str,
expected_model_url: str,
+ is_gateway: bool,
) -> None:
user = await create_user(session=session, global_role=GlobalRole.USER)
project = await create_project(session=session, owner=user, name="test-project")
@@ -2171,6 +2179,8 @@ async def test_submit_to_correct_proxy(
assert response.status_code == 200
assert response.json()["service"]["url"] == expected_service_url
assert response.json()["service"]["model"]["base_url"] == expected_model_url
+ events = await list_events(session)
+ assert ("Service registered in gateway" in {e.message for e in events}) == is_gateway
@pytest.mark.asyncio
async def test_return_error_if_specified_gateway_not_exists(
From 0c3e5648bcacec4ddedb18b02a1081565b8752bd Mon Sep 17 00:00:00 2001
From: Oleg
Date: Mon, 2 Feb 2026 13:30:38 +0300
Subject: [PATCH 047/640] [UI] Add Spot policy configuration option to the
fleet wizard #3513 (#3520)
---
frontend/src/hooks/useInfiniteScroll.ts | 60 ++++++++++++-------------
1 file changed, 30 insertions(+), 30 deletions(-)
diff --git a/frontend/src/hooks/useInfiniteScroll.ts b/frontend/src/hooks/useInfiniteScroll.ts
index b11115d63f..9064e6b346 100644
--- a/frontend/src/hooks/useInfiniteScroll.ts
+++ b/frontend/src/hooks/useInfiniteScroll.ts
@@ -41,10 +41,6 @@ export const useInfiniteScroll = ({
const [getItems, { isLoading, isFetching }] = useLazyQuery({ ...args } as Args);
const getDataRequest = (params: Args) => {
- if (isEqual(params, lastRequestParams.current)) {
- return Promise.reject();
- }
-
const request = getItems({
limit,
...params,
@@ -90,36 +86,40 @@ export const useInfiniteScroll = ({
return;
}
- try {
- isLoadingRef.current = true;
-
- const result = await getDataRequest({
- ...argsProp,
- ...getPaginationParams(data[data.length - 1]),
- } as Args);
-
- let listResponse: ListResponse;
+ const requestParams = {
+ ...argsProp,
+ ...getPaginationParams(data[data.length - 1]),
+ };
- if ('data' in result) {
- listResponse = result.data;
- setTotalCount(result.total_count);
- } else {
- listResponse = result;
- setTotalCount();
+ if (!isEqual(requestParams, lastRequestParams.current)) {
+ try {
+ isLoadingRef.current = true;
+
+ const result = await getDataRequest(requestParams as Args);
+
+ let listResponse: ListResponse;
+
+ if ('data' in result) {
+ listResponse = result.data;
+ setTotalCount(result.total_count);
+ } else {
+ listResponse = result;
+ setTotalCount();
+ }
+
+ if (listResponse.length > 0) {
+ setData((prev) => [...prev, ...listResponse]);
+ } else {
+ isDisabledMoreRef.current = true;
+ }
+ } catch (e) {
+ console.log(e);
}
- if (listResponse.length > 0) {
- setData((prev) => [...prev, ...listResponse]);
- } else {
- isDisabledMoreRef.current = true;
- }
- } catch (e) {
- console.log(e);
+ setTimeout(() => {
+ isLoadingRef.current = false;
+ }, 50);
}
-
- setTimeout(() => {
- isLoadingRef.current = false;
- }, 50);
};
useLayoutEffect(() => {
From 2acb6dee3db3960427ca346b7fa466b22e1fbcf4 Mon Sep 17 00:00:00 2001
From: Oleg
Date: Mon, 2 Feb 2026 20:00:42 +0300
Subject: [PATCH 048/640] [Feature]: Show probe statuses in the UI (#3521)
* [Feature]: Show probe statuses in the UI #3204
---
frontend/package-lock.json | 12 ++++-----
frontend/package.json | 2 +-
.../src/components/NavigateLink/index.tsx | 9 ++++++-
.../components/NavigateLink/style.module.scss | 5 ++++
frontend/src/libs/run.ts | 11 ++++++++
frontend/src/locale/en.json | 2 ++
.../pages/Runs/Details/Jobs/List/helpers.ts | 23 +++++++++++++++++
.../pages/Runs/Details/Jobs/List/hooks.tsx | 9 +++++++
.../pages/Runs/Details/RunDetails/index.tsx | 25 ++++++++++++++++++-
frontend/src/types/run.d.ts | 13 ++++++++++
10 files changed, 102 insertions(+), 9 deletions(-)
create mode 100644 frontend/src/components/NavigateLink/style.module.scss
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 445c61daa9..65e33fd915 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,7 +12,7 @@
"@cloudscape-design/chat-components": "^1.0.62",
"@cloudscape-design/collection-hooks": "^1.0.74",
"@cloudscape-design/component-toolkit": "^1.0.0-beta.120",
- "@cloudscape-design/components": "^3.0.1091",
+ "@cloudscape-design/components": "^3.0.1188",
"@cloudscape-design/design-tokens": "^3.0.60",
"@cloudscape-design/global-styles": "^1.0.45",
"@hookform/resolvers": "^2.9.10",
@@ -2137,9 +2137,9 @@
}
},
"node_modules/@cloudscape-design/components": {
- "version": "3.0.1091",
- "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1091.tgz",
- "integrity": "sha512-ESV83m/laX9OkuITjeucYRBi4WQSu9w8yniRZjRapiTH+zTlBxQv8Gcnvr9UYPo3cbYyig2HIdbAlOagDplgfA==",
+ "version": "3.0.1188",
+ "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1188.tgz",
+ "integrity": "sha512-Ajk7wFr2boPO7v4pgBLjdLVcA7myBiCk5hzpzURGcg+oogX9lYtXHv80k60dqyn1kzx+J0xnZwEjLa0oRmflmA==",
"license": "Apache-2.0",
"dependencies": {
"@cloudscape-design/collection-hooks": "^1.0.0",
@@ -2150,7 +2150,6 @@
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"ace-builds": "^1.34.0",
- "balanced-match": "^1.0.2",
"clsx": "^1.1.0",
"d3-shape": "^1.3.7",
"date-fns": "^2.25.0",
@@ -6979,7 +6978,8 @@
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
},
"node_modules/batch": {
"version": "0.6.1",
diff --git a/frontend/package.json b/frontend/package.json
index f2ef0d9ca1..ae701fc95f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -101,7 +101,7 @@
"@cloudscape-design/chat-components": "^1.0.62",
"@cloudscape-design/collection-hooks": "^1.0.74",
"@cloudscape-design/component-toolkit": "^1.0.0-beta.120",
- "@cloudscape-design/components": "^3.0.1091",
+ "@cloudscape-design/components": "^3.0.1188",
"@cloudscape-design/design-tokens": "^3.0.60",
"@cloudscape-design/global-styles": "^1.0.45",
"@hookform/resolvers": "^2.9.10",
diff --git a/frontend/src/components/NavigateLink/index.tsx b/frontend/src/components/NavigateLink/index.tsx
index eabf4002b5..7bac639685 100644
--- a/frontend/src/components/NavigateLink/index.tsx
+++ b/frontend/src/components/NavigateLink/index.tsx
@@ -1,6 +1,9 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import Link, { LinkProps } from '@cloudscape-design/components/link';
+
+import styles from './style.module.scss';
+
export const NavigateLink: React.FC = ({ onFollow, ...props }) => {
const navigate = useNavigate();
const onFollowHandler: LinkProps['onFollow'] = (event) => {
@@ -10,5 +13,9 @@ export const NavigateLink: React.FC = ({ onFollow, ...props }) => {
if (event.detail.href) navigate(event.detail.href);
};
- return ;
+ return (
+
+
+
+ );
};
diff --git a/frontend/src/components/NavigateLink/style.module.scss b/frontend/src/components/NavigateLink/style.module.scss
new file mode 100644
index 0000000000..27eca2e170
--- /dev/null
+++ b/frontend/src/components/NavigateLink/style.module.scss
@@ -0,0 +1,5 @@
+.link {
+ & > a {
+ text-decoration: underline !important;
+ }
+}
diff --git a/frontend/src/libs/run.ts b/frontend/src/libs/run.ts
index b1a626bf82..1c528b8b5f 100644
--- a/frontend/src/libs/run.ts
+++ b/frontend/src/libs/run.ts
@@ -4,6 +4,7 @@ import { StatusIndicatorProps } from '@cloudscape-design/components';
import { capitalize } from 'libs';
import { finishedRunStatuses } from '../pages/Runs/constants';
+import { getJobProbesStatuses } from '../pages/Runs/Details/Jobs/List/helpers';
import { IModelExtended } from '../pages/Models/List/types';
@@ -75,6 +76,16 @@ export const getRunError = (run: IRun): string | null => {
return error ? capitalize(error) : null;
};
+export const getRunProbeStatuses = (run: IRun): StatusIndicatorProps.Type[] => {
+ const job = run.jobs[0];
+
+ if (!job) {
+ return [];
+ }
+
+ return getJobProbesStatuses(run.jobs[0]);
+};
+
export const getRunPriority = (run: IRun): number | null => {
return run.run_spec.configuration?.priority ?? null;
};
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index e11541edba..db074ba2cd 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -402,6 +402,7 @@
"priority": "Priority",
"provider_name": "Provider",
"status": "Status",
+ "probe": "Probes",
"submitted_at": "Submitted",
"finished_at": "Finished",
"metrics": {
@@ -477,6 +478,7 @@
"offer": "Offer",
"offer_description": "Select an offer for the dev environment.",
"name": "Name",
+ "name_description": "The name of the run, e.g. 'my-dev-env'",
"name_constraint": "Example: 'my-fleet' or 'default'. If not specified, generated automatically.",
"name_placeholder": "Optional",
"ide": "IDE",
diff --git a/frontend/src/pages/Runs/Details/Jobs/List/helpers.ts b/frontend/src/pages/Runs/Details/Jobs/List/helpers.ts
index 71247dce6a..cb5ef5a468 100644
--- a/frontend/src/pages/Runs/Details/Jobs/List/helpers.ts
+++ b/frontend/src/pages/Runs/Details/Jobs/List/helpers.ts
@@ -1,4 +1,5 @@
import { format } from 'date-fns';
+import type { StatusIndicatorProps } from '@cloudscape-design/components/status-indicator';
import { DATE_TIME_FORMAT } from 'consts';
import { capitalize } from 'libs';
@@ -48,6 +49,28 @@ export const getJobStatus = (job: IJob) => {
return job.job_submissions?.[job.job_submissions.length - 1].status;
};
+export const getJobSubmissionProbes = (job: IJob) => {
+ return job.job_submissions?.[job.job_submissions.length - 1].probes;
+};
+
+export const getJobProbesStatuses = (job: IJob): StatusIndicatorProps.Type[] => {
+ const status = getJobStatus(job);
+ const probes = getJobSubmissionProbes(job);
+
+ if (!probes?.length || status !== 'running') {
+ return [];
+ }
+
+ return probes.map((probe, index) => {
+ if (job.job_spec?.probes?.[index] && probe.success_streak >= job.job_spec.probes[index].ready_after) {
+ return 'success';
+ } else if (probe.success_streak > 0) {
+ return 'in-progress';
+ }
+ return 'not-started';
+ });
+};
+
export const getJobTerminationReason = (job: IJob) => {
return job.job_submissions?.[job.job_submissions.length - 1].termination_reason ?? '-';
};
diff --git a/frontend/src/pages/Runs/Details/Jobs/List/hooks.tsx b/frontend/src/pages/Runs/Details/Jobs/List/hooks.tsx
index a5fabd5a2f..2d89cdcaef 100644
--- a/frontend/src/pages/Runs/Details/Jobs/List/hooks.tsx
+++ b/frontend/src/pages/Runs/Details/Jobs/List/hooks.tsx
@@ -15,6 +15,7 @@ import {
getJobListItemRegion,
getJobListItemResources,
getJobListItemSpot,
+ getJobProbesStatuses,
getJobStatus,
getJobStatusMessage,
getJobSubmittedAt,
@@ -67,6 +68,14 @@ export const useColumnsDefinitions = ({
);
},
},
+ {
+ id: 'probe',
+ header: t('projects.run.probe'),
+ cell: (item: IJob) => {
+ const statuses = getJobProbesStatuses(item);
+ return statuses.map((statusType, index) => );
+ },
+ },
{
id: 'priority',
header: t('projects.run.priority'),
diff --git a/frontend/src/pages/Runs/Details/RunDetails/index.tsx b/frontend/src/pages/Runs/Details/RunDetails/index.tsx
index 24e5c2718d..f878f367ff 100644
--- a/frontend/src/pages/Runs/Details/RunDetails/index.tsx
+++ b/frontend/src/pages/Runs/Details/RunDetails/index.tsx
@@ -7,7 +7,14 @@ import { format } from 'date-fns';
import { Box, ColumnLayout, Container, Header, Loader, NavigateLink, StatusIndicator } from 'components';
import { DATE_TIME_FORMAT } from 'consts';
-import { getRunError, getRunPriority, getRunStatusMessage, getStatusIconColor, getStatusIconType } from 'libs/run';
+import {
+ getRunError,
+ getRunPriority,
+ getRunProbeStatuses,
+ getRunStatusMessage,
+ getStatusIconColor,
+ getStatusIconType,
+} from 'libs/run';
import { ROUTES } from 'routes';
import { useGetRunQuery } from 'services/run';
@@ -65,6 +72,14 @@ export const RunDetails = () => {
const statusMessage = getRunStatusMessage(runData);
+ const renderRobeStatuses = () => {
+ const statuses = getRunProbeStatuses(runData);
+
+ if (!statuses.length) return '-';
+
+ return statuses.map((statusType, index) => );
+ };
+
return (
<>
{t('common.general')}}>
@@ -112,6 +127,7 @@ export const RunDetails = () => {
{t('projects.run.status')}
+
{
+ {runData.jobs.length <= 1 && (
+
+
{t('projects.run.probe')}
+
{renderRobeStatuses()}
+
+ )}
+
{t('projects.run.error')}
{getRunError(runData) ?? '-'}
diff --git a/frontend/src/types/run.d.ts b/frontend/src/types/run.d.ts
index 452d3a9f41..2e17206a05 100644
--- a/frontend/src/types/run.d.ts
+++ b/frontend/src/types/run.d.ts
@@ -171,6 +171,17 @@ declare interface IAppSpec {
url_query_params?: { [key: string]: string };
}
+declare interface IJobProbe {
+ type: 'http';
+ url: string;
+ method?: 'head' | 'post' | 'put' | 'patch' | 'delete' | 'get';
+ headers?: Array<{ name: string; value: string }>;
+ body?: string;
+ timeout: number;
+ interval: number;
+ ready_after: number;
+}
+
declare interface IJobSpec {
app_specs?: IAppSpec;
commands: string[];
@@ -181,6 +192,7 @@ declare interface IJobSpec {
job_num: number;
max_duration?: number;
working_dir: string;
+ probes?: IJobProbe[];
}
declare interface IGpu {
@@ -234,6 +246,7 @@ declare interface IJobSubmission {
exit_status?: number | null;
status_message?: string | null;
error?: string | null;
+ probes?: Array<{ success_streak: number }>;
}
declare interface IJob {
From c2d4655cb843da5c3666ff191e3275127de4d57a Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Tue, 3 Feb 2026 12:24:02 +0100
Subject: [PATCH 049/640] [Runpod] Make Community Cloud an "opt-in" (disable by
default) #3531 (#3534)
---
docs/docs/concepts/backends.md | 40 +++++++++----------
.../_internal/core/backends/runpod/models.py | 2 +-
2 files changed, 21 insertions(+), 21 deletions(-)
diff --git a/docs/docs/concepts/backends.md b/docs/docs/concepts/backends.md
index b601969337..d747dffd14 100644
--- a/docs/docs/concepts/backends.md
+++ b/docs/docs/concepts/backends.md
@@ -1105,10 +1105,27 @@ projects:
??? info "Community Cloud"
- By default, `dstack` considers instance offers from both the Secure Cloud and the
- [Community Cloud](https://docs.runpod.io/references/faq/#secure-cloud-vs-community-cloud).
+ By default, `dstack` considers instance offers only from the Secure Cloud.
+ To also include the
+ [Community Cloud](https://docs.runpod.io/references/faq/#secure-cloud-vs-community-cloud),
+ set `community_cloud: true` in the backend settings.
- You can tell them apart by their regions.
+
+
+ ```yaml
+ projects:
+ - name: main
+ backends:
+ - type: runpod
+ creds:
+ type: api_key
+ api_key: US9XTPDIV8AR42MMINY8TCKRB8S4E7LNRQ6CAUQ9
+ community_cloud: true
+ ```
+
+
+
+ You can tell Secure Cloud and Community Cloud apart by their regions.
Secure Cloud regions contain datacenter IDs such as `CA-MTL-3`.
Community Cloud regions contain country codes such as `CA`.
@@ -1124,23 +1141,6 @@ projects:
- If you don't want to use the Community Cloud, set `community_cloud: false` in the backend settings.
-
-
-
- ```yaml
- projects:
- - name: main
- backends:
- - type: runpod
- creds:
- type: api_key
- api_key: US9XTPDIV8AR42MMINY8TCKRB8S4E7LNRQ6CAUQ9
- community_cloud: false
- ```
-
-
-
### Vast.ai
Log into your [Vast.ai](https://cloud.vast.ai/) account, click Account in the sidebar, and copy your
diff --git a/src/dstack/_internal/core/backends/runpod/models.py b/src/dstack/_internal/core/backends/runpod/models.py
index 9c41c46034..076d67cfa0 100644
--- a/src/dstack/_internal/core/backends/runpod/models.py
+++ b/src/dstack/_internal/core/backends/runpod/models.py
@@ -4,7 +4,7 @@
from dstack._internal.core.models.common import CoreModel
-RUNPOD_COMMUNITY_CLOUD_DEFAULT = True
+RUNPOD_COMMUNITY_CLOUD_DEFAULT = False
class RunpodAPIKeyCreds(CoreModel):
From 851d8a6f002490edb6ecc8996e3dc5b212e5fbc3 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Tue, 3 Feb 2026 12:24:09 +0100
Subject: [PATCH 050/640] [Services] Add default probes if model is set
(#3524)
* [Services] Add default probes if model is set #3522
* Fixing `pyright`
* PR review feedback
* PR review feedback
* Resolved conflict
* Update src/dstack/_internal/server/services/jobs/configurators/base.py
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* Update src/dstack/_internal/core/models/configurations.py
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
* Fixing tests (missed import)
---------
Co-authored-by: jvstme <36324149+jvstme@users.noreply.github.com>
---
docs/docs/concepts/services.md | 10 +-
.../cli/services/configurators/run.py | 2 +-
.../_internal/core/models/configurations.py | 16 ++-
.../services/jobs/configurators/base.py | 35 ++++++-
.../_internal/server/services/runs/spec.py | 4 +-
.../core/models/test_configurations.py | 41 ++++++++
.../jobs/configurators/test_service.py | 98 +++++++++++++++++++
7 files changed, 197 insertions(+), 9 deletions(-)
create mode 100644 src/tests/_internal/server/services/jobs/configurators/test_service.py
diff --git a/docs/docs/concepts/services.md b/docs/docs/concepts/services.md
index fed9f7cb39..d40984866b 100644
--- a/docs/docs/concepts/services.md
+++ b/docs/docs/concepts/services.md
@@ -290,7 +290,7 @@ $ dstack ps --verbose
-??? info "Probe statuses"
+??? info "Status"
The following symbols are used for probe statuses:
- `×` — the last probe execution failed.
@@ -328,6 +328,11 @@ Probes are executed for each service replica while the replica is `running`. A p
+??? info "Model"
+ If you set the [`model`](#model) property but don't explicitly configure `probes`,
+ `dstack` automatically configures a default probe that tests the model using the `/v1/chat/completions` API.
+ To disable probes entirely when `model` is set, explicitly set `probes` to an empty list.
+
See the [reference](../reference/dstack.yml/service.md#probes) for more probe configuration options.
### Path prefix { #path-prefix }
@@ -425,6 +430,9 @@ Limits apply to the whole service (all replicas) and per client (by IP). Clients
If the service runs a model with an OpenAI-compatible interface, you can set the [`model`](#model) property to make the model accessible through `dstack`'s chat UI on the `Models` page.
In this case, `dstack` will use the service's `/v1/chat/completions` service.
+When `model` is set, `dstack` automatically configures [`probes`](#probes) to verify model health.
+To customize or disable this, set `probes` explicitly.
+
### Resources
If you specify memory size, you can either specify an explicit size (e.g. `24GB`) or a
diff --git a/src/dstack/_internal/cli/services/configurators/run.py b/src/dstack/_internal/cli/services/configurators/run.py
index fc76fe43ed..1077eff8a9 100644
--- a/src/dstack/_internal/cli/services/configurators/run.py
+++ b/src/dstack/_internal/cli/services/configurators/run.py
@@ -354,7 +354,7 @@ def interpolate_env(self, conf: RunConfigurationT):
password=interpolator.interpolate_or_error(conf.registry_auth.password),
)
if isinstance(conf, ServiceConfiguration):
- for probe in conf.probes:
+ for probe in conf.probes or []:
for header in probe.headers:
header.value = interpolator.interpolate_or_error(header.value)
if probe.url:
diff --git a/src/dstack/_internal/core/models/configurations.py b/src/dstack/_internal/core/models/configurations.py
index 3b2c7812b9..465df261b1 100644
--- a/src/dstack/_internal/core/models/configurations.py
+++ b/src/dstack/_internal/core/models/configurations.py
@@ -56,6 +56,8 @@
DEFAULT_PROBE_METHOD = "get"
MAX_PROBE_URL_LEN = 2048
DEFAULT_REPLICA_GROUP_NAME = "0"
+DEFAULT_MODEL_PROBE_TIMEOUT = 30
+DEFAULT_MODEL_PROBE_URL = "/v1/chat/completions"
class RunConfigurationType(str, Enum):
@@ -851,9 +853,13 @@ class ServiceConfigurationParams(CoreModel):
] = None
rate_limits: Annotated[list[RateLimit], Field(description="Rate limiting rules")] = []
probes: Annotated[
- list[ProbeConfig],
- Field(description="List of probes used to determine job health"),
- ] = []
+ Optional[list[ProbeConfig]],
+ Field(
+ description="The list of probes to determine service health. "
+ "If `model` is set, defaults to a `/v1/chat/completions` probe. "
+ "Set explicitly to override"
+ ),
+ ] = None # None = omitted (may get default when model is set); [] = explicit empty
replicas: Annotated[
Optional[Union[List[ReplicaGroup], Range[int]]],
@@ -895,7 +901,9 @@ def validate_rate_limits(cls, v: list[RateLimit]) -> list[RateLimit]:
return v
@validator("probes")
- def validate_probes(cls, v: list[ProbeConfig]) -> list[ProbeConfig]:
+ def validate_probes(cls, v: Optional[list[ProbeConfig]]) -> Optional[list[ProbeConfig]]:
+ if v is None:
+ return v
if has_duplicates(v):
# Using a custom validator instead of Field(unique_items=True) to avoid Pydantic bug:
# https://github.com/pydantic/pydantic/issues/3765
diff --git a/src/dstack/_internal/server/services/jobs/configurators/base.py b/src/dstack/_internal/server/services/jobs/configurators/base.py
index df6738a774..8e3d2374d7 100644
--- a/src/dstack/_internal/server/services/jobs/configurators/base.py
+++ b/src/dstack/_internal/server/services/jobs/configurators/base.py
@@ -5,12 +5,15 @@
from pathlib import PurePosixPath
from typing import Dict, List, Optional
+import orjson
from cachetools import TTLCache, cached
from dstack._internal import settings
from dstack._internal.core.errors import DockerRegistryError, ServerClientError
from dstack._internal.core.models.common import RegistryAuth
from dstack._internal.core.models.configurations import (
+ DEFAULT_MODEL_PROBE_TIMEOUT,
+ DEFAULT_MODEL_PROBE_URL,
DEFAULT_PROBE_INTERVAL,
DEFAULT_PROBE_METHOD,
DEFAULT_PROBE_READY_AFTER,
@@ -18,6 +21,7 @@
DEFAULT_PROBE_URL,
DEFAULT_REPLICA_GROUP_NAME,
LEGACY_REPO_DIR,
+ HTTPHeaderSpec,
PortMapping,
ProbeConfig,
PythonVersion,
@@ -39,6 +43,7 @@
Retry,
RunSpec,
)
+from dstack._internal.core.models.services import OpenAIChatModel
from dstack._internal.core.models.unix import UnixUser
from dstack._internal.core.models.volumes import MountPoint, VolumeMountPoint
from dstack._internal.core.services.profiles import get_retry
@@ -394,7 +399,13 @@ def _service_port(self) -> Optional[int]:
def _probes(self) -> list[ProbeSpec]:
if isinstance(self.run_spec.configuration, ServiceConfiguration):
- return list(map(_probe_config_to_spec, self.run_spec.configuration.probes))
+ probes = self.run_spec.configuration.probes
+ if probes is not None:
+ return list(map(_probe_config_to_spec, probes))
+ # Generate default probe if model is set
+ model = self.run_spec.configuration.model
+ if isinstance(model, OpenAIChatModel):
+ return [_default_model_probe_spec(model.name)]
return []
@@ -447,6 +458,28 @@ def _probe_config_to_spec(c: ProbeConfig) -> ProbeSpec:
)
+def _default_model_probe_spec(model_name: str) -> ProbeSpec:
+ body = orjson.dumps(
+ {
+ "model": model_name,
+ "messages": [{"role": "user", "content": "hi"}],
+ "max_tokens": 1,
+ }
+ ).decode("utf-8")
+ return ProbeSpec(
+ type="http",
+ method="post",
+ url=DEFAULT_MODEL_PROBE_URL,
+ headers=[
+ HTTPHeaderSpec(name="Content-Type", value="application/json"),
+ ],
+ body=body,
+ timeout=DEFAULT_MODEL_PROBE_TIMEOUT,
+ interval=DEFAULT_PROBE_INTERVAL,
+ ready_after=DEFAULT_PROBE_READY_AFTER,
+ )
+
+
def _join_shell_commands(commands: List[str]) -> str:
for i, cmd in enumerate(commands):
cmd = cmd.strip()
diff --git a/src/dstack/_internal/server/services/runs/spec.py b/src/dstack/_internal/server/services/runs/spec.py
index db81eb724a..ad2fcef1ff 100644
--- a/src/dstack/_internal/server/services/runs/spec.py
+++ b/src/dstack/_internal/server/services/runs/spec.py
@@ -94,13 +94,13 @@ def validate_run_spec_and_set_defaults(
raise ServerClientError(
"Scheduled services with autoscaling to zero are not supported"
)
- if len(run_spec.configuration.probes) > settings.MAX_PROBES_PER_JOB:
+ if len(run_spec.configuration.probes or []) > settings.MAX_PROBES_PER_JOB:
raise ServerClientError(
f"Cannot configure more than {settings.MAX_PROBES_PER_JOB} probes"
)
if any(
p.timeout is not None and p.timeout > settings.MAX_PROBE_TIMEOUT
- for p in run_spec.configuration.probes
+ for p in (run_spec.configuration.probes or [])
):
raise ServerClientError(
f"Probe timeout cannot be longer than {settings.MAX_PROBE_TIMEOUT}s"
diff --git a/src/tests/_internal/core/models/test_configurations.py b/src/tests/_internal/core/models/test_configurations.py
index 65eec62642..44c31f5cbb 100644
--- a/src/tests/_internal/core/models/test_configurations.py
+++ b/src/tests/_internal/core/models/test_configurations.py
@@ -7,12 +7,53 @@
from dstack._internal.core.models.configurations import (
DevEnvironmentConfigurationParams,
RepoSpec,
+ ServiceConfiguration,
parse_run_configuration,
)
from dstack._internal.core.models.resources import Range
class TestParseConfiguration:
+ def test_service_model_probes_none_when_omitted(self):
+ """When model is set but probes omitted, probes should remain None.
+ The default probe is generated server-side in the job configurator."""
+ conf = {
+ "type": "service",
+ "commands": ["python3 -m http.server"],
+ "port": 8000,
+ "model": "meta-llama/Meta-Llama-3.1-8B-Instruct",
+ }
+ parsed = parse_run_configuration(conf)
+ assert isinstance(parsed, ServiceConfiguration)
+ assert parsed.probes is None
+
+ def test_service_model_does_not_override_explicit_probes(self):
+ conf = {
+ "type": "service",
+ "commands": ["python3 -m http.server"],
+ "port": 8000,
+ "model": "meta-llama/Meta-Llama-3.1-8B-Instruct",
+ "probes": [{"type": "http", "url": "/health"}],
+ }
+ parsed = parse_run_configuration(conf)
+ assert isinstance(parsed, ServiceConfiguration)
+ assert parsed.probes is not None
+ assert len(parsed.probes) == 1
+ assert parsed.probes[0].url == "/health"
+
+ def test_service_model_explicit_empty_probes_no_default(self):
+ conf = {
+ "type": "service",
+ "commands": ["python3 -m http.server"],
+ "port": 8000,
+ "model": "meta-llama/Meta-Llama-3.1-8B-Instruct",
+ "probes": [],
+ }
+ parsed = parse_run_configuration(conf)
+ assert isinstance(parsed, ServiceConfiguration)
+ assert parsed.probes is not None
+ assert len(parsed.probes) == 0
+
def test_services_replicas_and_scaling(self):
def test_conf(replicas: Any, scaling: Optional[Any] = None):
conf = {
diff --git a/src/tests/_internal/server/services/jobs/configurators/test_service.py b/src/tests/_internal/server/services/jobs/configurators/test_service.py
new file mode 100644
index 0000000000..b52ee297a5
--- /dev/null
+++ b/src/tests/_internal/server/services/jobs/configurators/test_service.py
@@ -0,0 +1,98 @@
+import pytest
+
+from dstack._internal.core.models.configurations import (
+ DEFAULT_MODEL_PROBE_TIMEOUT,
+ DEFAULT_MODEL_PROBE_URL,
+ ProbeConfig,
+ ServiceConfiguration,
+)
+from dstack._internal.core.models.services import OpenAIChatModel
+from dstack._internal.server.services.jobs.configurators.service import ServiceJobConfigurator
+from dstack._internal.server.testing.common import get_run_spec
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("image_config_mock")
+class TestProbes:
+ async def test_default_probe_when_model_set(self):
+ """When model is set but probes omitted, a default model probe should be generated."""
+ configuration = ServiceConfiguration(
+ port=80,
+ image="debian",
+ model=OpenAIChatModel(
+ name="meta-llama/Meta-Llama-3.1-8B-Instruct",
+ format="openai",
+ ),
+ )
+ run_spec = get_run_spec(run_name="run", repo_id="id", configuration=configuration)
+ configurator = ServiceJobConfigurator(run_spec)
+
+ job_specs = await configurator.get_job_specs(replica_num=0)
+
+ assert len(job_specs) == 1
+ probes = job_specs[0].probes
+ assert len(probes) == 1
+ probe = probes[0]
+ assert probe.type == "http"
+ assert probe.method == "post"
+ assert probe.url == DEFAULT_MODEL_PROBE_URL
+ assert probe.timeout == DEFAULT_MODEL_PROBE_TIMEOUT
+ assert len(probe.headers) == 1
+ assert probe.headers[0].name == "Content-Type"
+ assert probe.headers[0].value == "application/json"
+ assert "meta-llama/Meta-Llama-3.1-8B-Instruct" in (probe.body or "")
+ assert "max_tokens" in (probe.body or "")
+
+ async def test_explicit_probes_not_overridden(self):
+ """When probes are explicitly set, they should be used as-is."""
+ configuration = ServiceConfiguration(
+ port=80,
+ image="debian",
+ model=OpenAIChatModel(
+ name="meta-llama/Meta-Llama-3.1-8B-Instruct",
+ format="openai",
+ ),
+ probes=[ProbeConfig(type="http", url="/health")],
+ )
+ run_spec = get_run_spec(run_name="run", repo_id="id", configuration=configuration)
+ configurator = ServiceJobConfigurator(run_spec)
+
+ job_specs = await configurator.get_job_specs(replica_num=0)
+
+ assert len(job_specs) == 1
+ probes = job_specs[0].probes
+ assert len(probes) == 1
+ assert probes[0].url == "/health"
+
+ async def test_explicit_empty_probes(self):
+ """When probes is explicitly set to empty list, no probes should be generated."""
+ configuration = ServiceConfiguration(
+ port=80,
+ image="debian",
+ model=OpenAIChatModel(
+ name="meta-llama/Meta-Llama-3.1-8B-Instruct",
+ format="openai",
+ ),
+ probes=[],
+ )
+ run_spec = get_run_spec(run_name="run", repo_id="id", configuration=configuration)
+ configurator = ServiceJobConfigurator(run_spec)
+
+ job_specs = await configurator.get_job_specs(replica_num=0)
+
+ assert len(job_specs) == 1
+ assert len(job_specs[0].probes) == 0
+
+ async def test_no_probe_when_no_model(self):
+ """When neither model nor probes are set, no probes should be generated."""
+ configuration = ServiceConfiguration(
+ port=80,
+ image="debian",
+ )
+ run_spec = get_run_spec(run_name="run", repo_id="id", configuration=configuration)
+ configurator = ServiceJobConfigurator(run_spec)
+
+ job_specs = await configurator.get_job_specs(replica_num=0)
+
+ assert len(job_specs) == 1
+ assert len(job_specs[0].probes) == 0
From 2410c47f52bf7879b345cb1c8706959e2c34a3e2 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Tue, 3 Feb 2026 15:08:11 +0100
Subject: [PATCH 051/640] [Docs] Update SKILL.md (#3536)
* [Docs] Enhance dstack SKILL.md with user request interpretations and best practices (WIP)
* Major update of SKILL.md (especially around `dstack attach`).
* [Docs] Added `Install agent skills` to `Installation`
---
docs/docs/installation.md | 60 +++++----
skills/dstack/SKILL.md | 277 ++++++++++++++++----------------------
2 files changed, 150 insertions(+), 187 deletions(-)
diff --git a/docs/docs/installation.md b/docs/docs/installation.md
index 725611f08e..83b1602b10 100644
--- a/docs/docs/installation.md
+++ b/docs/docs/installation.md
@@ -109,30 +109,6 @@ Once the server is up, you can access it via the `dstack` CLI.
(or `Use Git and optional Unix tools from the Command Prompt`), and
`Use bundled OpenSSH`.
-### Configure the default project
-
-To point the CLI to the `dstack` server, configure it
-with the server address, user token, and project name:
-
-
-
-```shell
-$ dstack project add \
- --name main \
- --url http://127.0.0.1:3000 \
- --token bbae0f28-d3dd-4820-bf61-8f4bb40815da
-
-Configuration is updated at ~/.dstack/config.yml
-```
-
-
-
-This configuration is stored in `~/.dstack/config.yml`.
-
-
-
-
-
??? info "Shell autocompletion"
`dstack` supports shell autocompletion for `bash` and `zsh`.
@@ -199,9 +175,41 @@ This configuration is stored in `~/.dstack/config.yml`.
> If you get an error similar to `2: command not found: compdef`, then add the following line to the beginning of your `~/.zshrc` file:
> `autoload -Uz compinit && compinit`.
+### Configure the default project
+
+To point the CLI to the `dstack` server, configure it
+with the server address, user token, and project name:
+
+
+
+```shell
+$ dstack project add \
+ --name main \
+ --url http://127.0.0.1:3000 \
+ --token bbae0f28-d3dd-4820-bf61-8f4bb40815da
+
+Configuration is updated at ~/.dstack/config.yml
+```
+
+
+
+This configuration is stored in `~/.dstack/config.yml`.
+
+### Install agent skills
+
+Install [skills](https://skills.sh/dstackai/dstack/dstack) to help AI agents use the `dstack` CLI and edit configuration files.
+
+
+
+```shell
+$ npx skills add dstackai/dstack
+```
+
+
+
+Agent skills are experimental. Share your feedback via [GitHub issues](https://github.com/dstackai/dstack/issues).
+
!!! info "What's next?"
1. See [Backends](concepts/backends.md)
2. Follow [Quickstart](quickstart.md)
3. Check the [server deployment](guides/server-deployment.md) guide
- 4. Browse [examples](../examples.md)
- 5. Join the community via [Discord](https://discord.gg/u8SmfwPpMd)
diff --git a/skills/dstack/SKILL.md b/skills/dstack/SKILL.md
index e1d641c9a1..8ae5ce2696 100644
--- a/skills/dstack/SKILL.md
+++ b/skills/dstack/SKILL.md
@@ -8,35 +8,33 @@ description: |
## Overview
-`dstack` is a tool that allows to provision and orchestrate GPU workloads across [GPU clouds and Kubernetes clusters](https://dstack.ai/docs/concepts/backends.md) or on-prem clusters (SSH fleets).
+`dstack` provisions and orchestrates workloads across GPU clouds, Kubernetes, and on-prem via fleets.
**When to use this skill:**
-- Running/managing GPU workloads (dev environments, tasks for training or other batch jobs, services to run inference or deploy web apps)
-- Creating, editing, and running `dstack` configurations
-- Managing fleets of compute (instances/clusters)
+- Running or managing dev environments, tasks, or services on dstack
+- Creating, editing, or applying `*.dstack.yml` configurations
+- Managing fleets, volumes, and resource availability
## How it works
`dstack` operates through three core components:
1. `dstack` server - Can run locally, remotely, or via dstack Sky (managed)
-2. `dstack` CLI - For applying configurations and managing resources; CLI can be pointed to the server and a particular default project (`~/.dstack/config.yml` or via `dstack project` CLI command); other CLI commands use the default project
+2. `dstack` CLI - Applies configurations and manages resources; the CLI can be pointed to a server and default project (`~/.dstack/config.yml` or via `dstack project`)
3. `dstack` configuration files - YAML files ending with `.dstack.yml`
-**Typical workflow:**
-```bash
-# 1. Define configuration in YAML file (e.g., train.dstack.yml, .dstack.yml, llama-serve.dstack.yml)
-# 2. Apply configuration
-dstack apply -f train.dstack.yml
+`dstack apply` plans, provisions cloud resources, and schedules containers/runners. By default it attaches when the run reaches `running` (opens SSH tunnel, forwards ports, streams logs). With `-d`, it submits and exits.
-# 3. dstack prepares a plan, and once confirmed, provisions instances (according to created fleets) and runs workloads
-# 4. Monitor with `dstack ps`, `dstack logs`, `dstack attach`, etc. (these commands support various options).
-```
+## Quick agent flow (detached runs)
-By default, `dstack apply` requires a confirmation, and once first job within the run is `running` - it "attaches" establishes an SSH tunnel, forwards ports if any and streams logs in real-time; if you pass `-d`, it runs in the detached mode and exits once the run is submitted.
+1) Show plan: `echo "n" | dstack apply -f `
+2) If plan is OK and user confirms, apply detached: `dstack apply -f -y -d`
+3) Check status once: `dstack ps -v`
+4) If dev-environment or task with ports and running: attach to surface IDE link/ports/SSH alias (agent runs attach in background); ask to open link
+5) If attach fails in sandbox: request escalation; if not approved, ask the user to run `dstack attach` locally and share the output
**CRITICAL: Never propose `dstack` CLI commands or YAML syntaxes that don't exist.**
-- Only use CLI commands and YAML syntax explicitly documented in this skill file or verified via `--help`
+- Only use CLI commands and YAML syntax documented here or verified via `--help`
- If uncertain about a command or its syntax, check the links or use `--help`
**NEVER do the following:**
@@ -50,8 +48,6 @@ By default, `dstack apply` requires a confirmation, and once first job within th
## Agent execution guidelines
-**This section provides critical guidance for AI agents executing dstack commands.**
-
### Output accuracy
- **NEVER reformat, summarize, or paraphrase CLI output.** Display tables, status output, and error messages exactly as returned.
- When showing command results, use code blocks to preserve formatting.
@@ -70,40 +66,88 @@ By default, `dstack apply` requires a confirmation, and once first job within th
### Command timing and confirmation handling
-**Commands that run indefinitely (agents should avoid these):**
-- `dstack attach` - maintains connection until interrupted
-- `dstack apply` without `-d` for runs - streams logs after provisioning
-- `dstack ps -w` - watch mode, auto-refreshes until interrupted
+**Commands that stream indefinitely in the foreground:**
+- `dstack attach`
+- `dstack apply` without `-d` for runs
+- `dstack ps -w`
-Instead, use `dstack ps -v` to check status, or `dstack apply -d` for detached mode.
+Agents should avoid blocking: use `-d`, timeouts, or background attach. When attach is needed, run it in the background by default (`nohup ...`), but describe it to the user simply as "attach" unless they ask for a live foreground session. Prefer `dstack ps -v` and poll in a loop if the user wants to watch status.
**All other commands:** Use 10-60s timeout. Most complete within this range. **While waiting, monitor the output** - it may contain errors, warnings, or prompts requiring attention.
**Confirmation handling:**
- `dstack apply`, `dstack stop`, `dstack fleet delete` require confirmation
- Use `-y` flag to auto-confirm when user has already approved
+- For `dstack stop`, always use `-y` after the user confirms to avoid interactive prompts
- Use `echo "n" |` to preview `dstack apply` plan without executing (avoid `echo "y" |`, prefer `-y`)
**Best practices:**
- Prefer modifying configuration files over passing parameters to `dstack apply` (unless it's an exception)
- When user confirms deletion/stop operations, use `-y` flag to skip confirmation prompts
-- Avoid waiting indefinitely; display essential output once command is finished (even if by timeout)
+
+### Detached run follow-up (after `-d`)
+
+After submitting a run with `-d` (dev-environment, task, service), first determine whether submission failed. If the apply output shows errors (validation, no offers, etc.), stop and surface the error.
+
+If the run was submitted, do a quick status check with `dstack ps -v`, then guide the user through relevant next steps:
+If you need to prompt for next actions, be explicit about the dstack step and command (avoid vague questions). When speaking to the user, refer to the action as "attach" (not "background attach").
+- **Monitor status:** Report the current status (provisioning/pulling/running/finished) and offer to keep watching. Poll `dstack ps -v` every 10-20s if the user wants updates.
+- **Attach when running:** For agents, run attach in the background by default so the session does not block. Use it to capture IDE links/SSH alias or enable port forwarding; when describing the action to the user, just say "attach".
+- **Dev environments or tasks with ports:** Once `running`, attach to surface the IDE link/port forwarding/SSH alias, then ask whether to open the IDE link. Never open links without explicit approval.
+- **Services:** Prefer using service endpoints. Attach only if the user explicitly needs port forwarding or full log replay.
+- **Tasks without ports:** Default to `dstack logs` for progress; attach only if full log replay is required.
+
+### Attaching behavior (blocking vs non-blocking)
+
+`dstack attach` runs until interrupted and blocks the terminal. **Agents must avoid indefinite blocking.** If a brief attach is needed, use a timeout to capture initial output (IDE link, SSH alias) and then detach.
+
+Note: `dstack attach` writes SSH alias info under `~/.dstack/ssh/config` (and may update `~/.ssh/config`) to enable `ssh `, IDE connections, port forwarding, and real-time logs (`dstack attach --logs`). If the sandbox cannot write there, the alias will not be created.
+
+**Background attach (non-blocking default for agents):**
+```bash
+nohup dstack attach --logs > /tmp/.attach.log 2>&1 & echo $! > /tmp/.attach.pid
+```
+Then read the output:
+```bash
+tail -n 50 /tmp/.attach.log
+```
+Offer live follow only if asked:
+```bash
+tail -f /tmp/.attach.log
+```
+Stop the background attach (preferred):
+```bash
+kill "$(cat /tmp/.attach.pid)"
+```
+If the PID file is missing, fall back to a specific match (avoid killing all attaches):
+```bash
+pkill -f "dstack attach "
+```
+**Why this helps:** it keeps the attach session alive (including port forwarding) while the agent remains usable. IDE links and SSH instructions appear in the log file -- surface them and ask whether to open the link (`open " "` on macOS, `xdg-open " "` on Linux) only after explicit approval.
+
+If background attach fails in the sandbox (permissions writing `~/.dstack` or `~/.ssh`, timeouts), request escalation to run attach outside the sandbox. If not approved, ask the user to run attach locally and share the IDE link/SSH alias.
+
+### Interpreting user requests
+
+**"Run something":** When the user asks to run a workload (dev environment, task, service), use `dstack apply` with the appropriate configuration. Note: `dstack run` only supports `dstack run get --json` for retrieving run details -- it cannot start workloads.
+
+**"Connect to" or "open" a dev environment:** If a dev environment is already running, use `dstack attach --logs` (agent runs it in the background by default) to surface the IDE URL (`cursor://`, `vscode://`, etc.) and SSH alias. If sandboxed attach fails, request escalation or ask the user to run attach locally and share the link.
## Configuration types
-`dstack` supports five main configuration types, each with specific use cases. Configuration files can be named `.dstack.yml` or simply `.dstack.yml`.
+`dstack` supports five main configuration types. Configuration files can be named `.dstack.yml` or simply `.dstack.yml`.
**Common parameters:** All run configurations (dev environments, tasks, services) support many parameters including:
- **Git integration:** Clone repos automatically (`repo`), mount existing repos (`repos`), upload local files (`working_dir`)
-- **Docker support:** Use custom Docker images (`image`); Also if needed, use `docker: true` if you want to use `docker` from inside the container (VM-based backends only)
-- **Environment & secrets:** Set environment variables (`env`), reference secrets
+- **File upload:** `files` (see concept docs for examples)
+- **Docker support:** Use custom Docker images (`image`); use `docker: true` if you want to use Docker from inside the container (VM-based backends only)
+- **Environment:** Set environment variables (`env`), often via `.envrc`. Secrets are supported but less common.
- **Storage:** Persistent network volumes (`volumes`), specify disk size
- **Resources:** Define GPU, CPU, memory, and disk requirements
**Best practices:**
- - Prefer giving configurations a `name` property for easier management
-
-See configuration reference pages for complete parameter lists.
+- Prefer giving configurations a `name` property for easier management
+- When configurations need credentials (API keys, tokens), list only env var names in the `env` section (e.g., `- HF_TOKEN`), not values. Recommend storing actual values in a `.envrc` file alongside the configuration, applied via `source .envrc && dstack apply`.
### 1. Dev environments
**Use for:** Interactive development with IDE integration (VS Code, Cursor, etc.).
@@ -117,7 +161,6 @@ ide: vscode
resources:
gpu: 80GB
- disk: 500GB
```
[Concept documentation](https://dstack.ai/docs/concepts/dev-environments.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/dev-environment.md)
@@ -125,7 +168,7 @@ resources:
### 2. Tasks
**Use for:** Batch jobs, training runs, fine-tuning, web applications, any executable workload.
-**Key features:** Distributed training (multi-node), port forwarding for web apps.
+**Key features:** Distributed training (multi-node) and port forwarding for web apps.
```yaml
type: task
@@ -141,19 +184,12 @@ ports:
- 8501 # Optional: expose ports for web apps
resources:
- gpu: A100:40GB:2 # Two 40GB A100s
- disk: 200GB
+ gpu: A100:40GB:2
```
-**Port forwarding:** When you specify `ports`, `dstack apply` automatically forwards them to `localhost` while attached. Use `dstack attach ` to reconnect and restore port forwarding. The run name becomes an SSH alias (e.g., `ssh `) for direct access.
+**Port forwarding:** When you specify `ports`, `dstack apply` forwards them to `localhost` while attached. Use `dstack attach ` to reconnect and restore port forwarding. The run name becomes an SSH alias (e.g., `ssh `) for direct access.
-**Examples:**
-- [Single-node training (TRL)](https://dstack.ai/examples/single-node-training/trl/index.md)
-- [Single-node training (Axolotl)](https://dstack.ai/examples/single-node-training/axolotl/index.md)
-- [Distributed training (TRL)](https://dstack.ai/examples/distributed-training/trl/index.md)
-- [Distributed training (Axolotl)](https://dstack.ai/examples/distributed-training/axolotl/index.md)
-- [Distributed training (Ray+RAGEN)](https://dstack.ai/examples/distributed-training/ray-ragen/index.md)
-- [NCCL/RCCL tests](https://dstack.ai/examples/clusters/nccl-rccl-tests/index.md)
+**Distributed training:** Multi-node tasks are supported (e.g., via `nodes`) and require fleets that support inter-node communication (see `placement: cluster` in fleets).
[Concept documentation](https://dstack.ai/docs/concepts/tasks.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/task.md)
@@ -178,63 +214,36 @@ model: meta-llama/Meta-Llama-3.1-8B-Instruct
resources:
gpu: 80GB
disk: 200GB
-
```
-Once a service is `running` and its health probes are green:
-
**Service endpoints:**
- Without gateway: `/proxy/services///`
- With gateway: `https://./`
-
-**Example:**
-```bash
-curl http://localhost:3000/proxy/services///v1/chat/completions \
- -H 'Content-Type: application/json' \
- -H 'Authorization: Bearer ' \
- -d '{"model": "meta-llama/Meta-Llama-3.1-8B-Instruct", "messages": [{"role": "user", "content": "Hello!"}]}'
-```
-
-**Gateways:** Set up a [gateway](https://dstack.ai/docs/concepts/gateways.md) before running services to enable custom domains, HTTPS, auto-scaling rate limits, and production-grade endpoint management. Use the `dstack gateway` CLI command to manage gateways.
-
-**Examples:**
-- [SGLang](https://dstack.ai/examples/inference/sglang/index.md)
-- [vLLM](https://dstack.ai/examples/inference/vllm/index.md)
-- [NIM](https://dstack.ai/examples/inference/nim/index.md)
-- [TensorRT-LLM](https://dstack.ai/examples/inference/trtllm/index.md)
+- For OpenAI-compatible models, use the `/v1/...` paths under the service URL and pass the dstack token in the `Authorization` header.
[Concept documentation](https://dstack.ai/docs/concepts/services.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/service.md)
### 4. Fleets
-**Use for:** Pre-provisioning infrastructure for workloads, managing on-premises GPU servers, creating auto-scaling instance pools.
-
-**Important:** Workloads (dev environments, tasks, services) only run if their resource requirements match at least one configured fleet. Without matching fleets, provisioning will fail.
-
-dstack supports two fleet types:
-
-#### Backend fleets (Cloud/Kubernetes)
-Dynamically provision instances from configured [backends](https://dstack.ai/docs/concepts/backends.md). Use the `nodes` property for on-demand scaling:
+**Use for:** Pre-provisioning infrastructure for workloads, managing on-prem GPU servers, creating auto-scaling instance pools.
```yaml
type: fleet
name: my-fleet
-nodes: 0..2 # Range: creates template when starting with 0, provisions on-demand
+nodes: 0..2
resources:
- gpu: 24GB.. # 24GB or more
+ gpu: 24GB..
disk: 200GB
-spot_policy: auto # auto (default), spot, or on-demand
-idle_duration: 5m # Terminate idle instances after 5 minutes
+spot_policy: auto
+idle_duration: 5m
```
-**On-demand provisioning:** When `nodes` is a range (e.g., `0..2`, `1..10`), dstack creates an instance template. Instances are provisioned automatically when workloads need them, scaling between min and max. Set `idle_duration` to terminate idle instances.
-
-**Additional options:** Fleets support many configuration options including `placement: cluster` for multi-node distributed workloads requiring inter-node communication (e.g., multi-GPU training), `blocks` for resource isolation, environment variables, and more. See the configuration reference for complete details.
+**On-demand provisioning:** When `nodes` is a range (e.g., `0..2`), dstack creates a template and provisions instances on demand within the min/max. Use `idle_duration` to terminate idle instances.
-#### SSH fleets (on-prem or pre-provisioned clusters)
-Use existing GPU servers accessible via SSH:
+**Distributed workloads:** Use `placement: cluster` for fleets intended for multi-node tasks that require inter-node networking.
+**SSH fleet (on-prem or pre-provisioned):**
```yaml
type: fleet
name: on-prem-fleet
@@ -250,14 +259,8 @@ ssh_config:
[Concept documentation](https://dstack.ai/docs/concepts/fleets.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/fleet.md)
### 5. Volumes
-**Use for:** Persistent storage for datasets, model checkpoints, training artifacts that persist across runs and can be shared between workloads.
-
-dstack supports two types of volumes:
+**Use for:** Persistent storage for datasets, model checkpoints, training artifacts.
-#### Network Volumes
-Backend-specific persistent volumes (AWS EBS, GCP Persistent Disk, etc.) that can be attached to any dev environment, task, or service.
-
-**Define a network volume:**
```yaml
type: volume
name: my-volume
@@ -269,28 +272,20 @@ resources:
disk: 500GB
```
-**Attach to workloads via `volumes` property:**
-```yaml
-type: task
-# ... other config
-volumes:
- - name: my-volume
- path: /volume_data
-```
-
-#### Instance Volumes
-Faster local volumes using the instance's root disk. Ideal for ephemeral storage, caching, or maximum I/O performance without persistence across instances.
-
-**Attach instance volumes via `volumes` property:**
+**Instance volumes (local, ephemeral, often optional):**
```yaml
type: dev-environment
# ... other config
volumes:
- - name: my-instance-volume
- path: /cache_data
+ - instance_path: /dstack-cache/pip
+ path: /root/.cache/pip
+ optional: true
+ - instance_path: /dstack-cache/huggingface
+ path: /root/.cache/huggingface
+ optional: true
```
-**Note:** Volumes can be attached to dev environments, tasks, and services using the `volumes` property. Network volumes persist independently, while instance volumes are tied to the instance lifecycle.
+**Attach to runs:** Use `volumes` in dev environments, tasks, and services. Network volumes persist independently; instance volumes are tied to the instance lifecycle.
[Concept documentation](https://dstack.ai/docs/concepts/volumes.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/volume.md)
@@ -299,16 +294,11 @@ volumes:
### Apply configurations
**Important behavior:**
-- `dstack apply` shows a plan with estimated costs and may ask for confirmation (respond with `y` or use `-y` flag to skip)
-- Once confirmed, it provisions infrastructure and streams real-time output to the terminal
-- In attached mode (default), the terminal blocks and shows output - use timeout or Ctrl+C to interrupt if you need to continue with other commands
+- `dstack apply` shows a plan with estimated costs and may ask for confirmation
+- In attached mode (default), the terminal blocks and shows output
- In detached mode (`-d`), runs in background without blocking the terminal
-**Workflow for applying configurations:**
-
-> **Critical for agents:** Always show the plan first, wait for user confirmation, THEN execute. Never auto-execute without user approval.
-
-**Step-by-step for run configurations (dev-environment, task, service):**
+**Workflow for applying run configurations (dev-environment, task, service):**
1. **Show plan:**
```bash
@@ -330,9 +320,8 @@ volumes:
```bash
dstack ps -v
```
- Show the run status. Look for the run name and status column.
-**Step-by-step for infrastructure (fleet, volume, gateway):**
+**Workflow for infrastructure (fleet, volume, gateway):**
1. **Show plan:**
```bash
@@ -349,25 +338,7 @@ volumes:
4. **Verify:** Use `dstack fleet`, `dstack volume`, or `dstack gateway` respectively.
-**Common apply patterns:**
-```bash
-# Apply and attach (interactive, blocks terminal with port forwarding)
-dstack apply -f train.dstack.yml
-
-# Apply with automatic confirmation
-dstack apply -f train.dstack.yml -y
-
-# Apply detached (background, no attachment)
-dstack apply -f serve.dstack.yml -d
-
-# Force rerun (recreates even if run with same name exists)
-dstack apply -f finetune.dstack.yml --force
-
-# Override defaults (prefer modifying config file instead, unless it's an exception)
-dstack apply -f .dstack.yml --max-price 2.5
-```
-
-### Fleet Management
+### Fleet management
```bash
# Create/update fleet
@@ -385,7 +356,7 @@ dstack fleet get my-fleet --json
# Delete entire fleet (use -y when user already confirmed)
dstack fleet delete my-fleet -y
-# IMPORTANT: When asked to delete an instance, always use -i - do NOT delete the entire fleet (use -y when user already confirmed)
+# Delete specific instance from fleet (use -y when user already confirmed)
dstack fleet delete my-fleet -i -y
```
@@ -395,22 +366,18 @@ dstack fleet delete my-fleet -i -y
# List all runs
dstack ps
-# JSON output (for troubleshooting/scripting)
-dstack ps --json
-
# Verbose output with full details
dstack ps -v
+# JSON output (for troubleshooting/scripting)
+dstack ps --json
+
# Get specific run details as JSON
dstack run get my-run-name --json
```
### Attach to runs
-**What is attaching?** Attaching connects to an existing run to restore port forwarding (for tasks/services with ports) and enable SSH access. The run name becomes an SSH alias (e.g., `ssh my-run-name`) configured in `~/.dstack/ssh/config` (included to `~/.ssh/config`).
-
-**Note:** `dstack apply` automatically attaches when run completes provisioning. Use `dstack attach` to reconnect after detaching or to access detached runs.
-
```bash
# Attach and replay logs from start (preferred, unless asked otherwise)
dstack attach my-run-name --logs
@@ -438,10 +405,7 @@ dstack logs my-run-name --job 0
### Stop runs
```bash
-# Stop specific run
-dstack stop my-run-name
-
-# Stop with confirmation skipped (use when user already confirmed)
+# Stop specific run (use -y after user confirms)
dstack stop my-run-name -y
# Abort (force stop)
@@ -450,8 +414,6 @@ dstack stop my-run-name --abort
### Check available resources
-**Use `dstack offer` to verify GPU availability before provisioning:**
-
```bash
# List all available offers across backends
dstack offer --json
@@ -465,18 +427,11 @@ dstack offer --gpu A100
# Filter by GPU memory
dstack offer --gpu 24GB..80GB
-# JSON output for detailed inspection
-dstack offer --json
-
# Combine filters
dstack offer --backend aws --gpu A100:80GB
```
-**Note:** `dstack offer` shows all available GPU instances from configured backends, not just those matching configured fleets. Use it to check backend availability, but remember: an offer appearing here doesn't guarantee a fleet will provision it - fleets have their own resource constraints.
-
-### Expected Output Formats
-
-**Agents should display these tables as-is, preserving column alignment.**
+**Note:** `dstack offer` lists offers across backends regardless of configured fleets.
## Troubleshooting
@@ -491,22 +446,22 @@ When diagnosing issues with dstack workloads or infrastructure:
2. **Check verbose run status:**
```bash
- dstack ps -v # Shows provisioning state, instance details, errors
+ dstack ps -v
```
3. **Examine logs with debug output:**
```bash
- dstack logs my-run -d # Includes additional runner logs
+ dstack logs my-run -d
```
4. **Attach with log replay:**
```bash
- dstack attach my-run --logs # See full output from start
+ dstack attach my-run --logs
```
5. **Verify resource availability:**
```bash
- dstack offer --backend aws --gpu A100 --spot-auto --json # Check if resources exist
+ dstack offer --backend aws --gpu A100 --spot-auto --json
```
Common issues:
@@ -521,7 +476,7 @@ Common issues:
2. Do NOT retry the same command without addressing the error
3. Refer to the [Troubleshooting guide](https://dstack.ai/docs/guides/troubleshooting.md) for guidance
-## Additional Resources
+## Additional resources
**Core documentation:**
- [Overview](https://dstack.ai/docs/overview.md)
@@ -529,13 +484,13 @@ Common issues:
- [Quickstart](https://dstack.ai/docs/quickstart.md)
**Additional concepts:**
-- [Secrets](https://dstack.ai/docs/concepts/secrets.md) - Manage sensitive credentials
-- [Projects](https://dstack.ai/docs/concepts/projects.md) - Projects isolate the resources of different teams
-- [Metrics](https://dstack.ai/docs/concepts/metrics.md) - Track GPU utilization
-- [Events](https://dstack.ai/docs/concepts/events.md) - Monitor system events
+- [Secrets](https://dstack.ai/docs/concepts/secrets.md)
+- [Projects](https://dstack.ai/docs/concepts/projects.md)
+- [Metrics](https://dstack.ai/docs/concepts/metrics.md)
+- [Events](https://dstack.ai/docs/concepts/events.md)
**Guides:**
-- [Server deployment](https://dstack.ai/docs/guides/server-deployment.md) (for server administration)
+- [Server deployment](https://dstack.ai/docs/guides/server-deployment.md)
- [Pro tips](https://dstack.ai/docs/guides/protips.md)
**Accelerator-specific examples:**
From 652c8d8df961d1c7a7e95c1de4b30ec6ab496141 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 4 Feb 2026 08:35:02 +0000
Subject: [PATCH 052/640] [CLI]: `dstack event --watch` (#3533)
This adds the new `--watch` option to the
`dstack event` command, which allows tracking
events in realtime.
---
src/dstack/_internal/cli/commands/event.py | 19 +-
src/dstack/_internal/cli/services/events.py | 81 +++-
src/dstack/_internal/server/routers/events.py | 4 +
.../_internal/cli/services/test_events.py | 349 ++++++++++++++++++
4 files changed, 449 insertions(+), 4 deletions(-)
create mode 100644 src/tests/_internal/cli/services/test_events.py
diff --git a/src/dstack/_internal/cli/commands/event.py b/src/dstack/_internal/cli/commands/event.py
index 4fd122d449..709a14d826 100644
--- a/src/dstack/_internal/cli/commands/event.py
+++ b/src/dstack/_internal/cli/commands/event.py
@@ -2,7 +2,12 @@
from dataclasses import asdict
from dstack._internal.cli.commands import APIBaseCommand
-from dstack._internal.cli.services.events import EventListFilters, EventPaginator, print_event
+from dstack._internal.cli.services.events import (
+ EventListFilters,
+ EventPaginator,
+ EventTracker,
+ print_event,
+)
from dstack._internal.cli.utils.common import (
get_start_time,
)
@@ -29,6 +34,12 @@ def _register(self):
list_parser.set_defaults(subfunc=self._list)
for parser in [self._parser, list_parser]:
+ parser.add_argument(
+ "-w",
+ "--watch",
+ help="Watch events in realtime",
+ action="store_true",
+ )
parser.add_argument(
"--since",
help=(
@@ -106,7 +117,11 @@ def _list(self, args: argparse.Namespace):
since = get_start_time(args.since)
filters = _build_filters(args, self.api)
- if since is not None:
+ if args.watch:
+ events = EventTracker(
+ client=self.api.client.events, filters=filters, since=since
+ ).stream_forever()
+ elif since is not None:
events = EventPaginator(self.api.client.events).list(
filters=filters, since=since, ascending=True
)
diff --git a/src/dstack/_internal/cli/services/events.py b/src/dstack/_internal/cli/services/events.py
index 0f0eb0f4b4..37ce9e1eb0 100644
--- a/src/dstack/_internal/cli/services/events.py
+++ b/src/dstack/_internal/cli/services/events.py
@@ -1,12 +1,13 @@
+import time
import uuid
from collections.abc import Iterator
from dataclasses import asdict, dataclass
-from datetime import datetime
+from datetime import datetime, timedelta
from typing import Optional
from rich.text import Text
-from dstack._internal.cli.utils.common import console
+from dstack._internal.cli.utils.common import LIVE_TABLE_PROVISION_INTERVAL_SECS, console
from dstack._internal.core.models.events import Event, EventTargetType
from dstack._internal.server.schemas.events import LIST_EVENTS_DEFAULT_LIMIT
from dstack.api.server._events import EventsAPIClient
@@ -50,6 +51,82 @@ def list(
prev_recorded_at = events[-1].recorded_at
+class EventTracker:
+ """
+ Tracks new events from the server. Implements a sliding window mechanism to avoid
+ missing events that are commited with a delay.
+ """
+
+ def __init__(
+ self,
+ client: EventsAPIClient,
+ filters: EventListFilters,
+ since: Optional[datetime],
+ event_delay_tolerance: timedelta = timedelta(seconds=20),
+ ) -> None:
+ self._client = client
+ self._filters = filters
+ self._since = since
+ self._event_delay_tolerance = event_delay_tolerance
+ self._seen_events: dict[uuid.UUID, _SeenEvent] = {}
+ self._latest_event: Optional[Event] = None
+
+ def poll(self) -> Iterator[Event]:
+ """
+ Fetches the next batch of events from the server.
+ """
+
+ if self._since is None and self._latest_event is None:
+ # First batch without `since` - fetch some recent events
+ event_stream = reversed(self._client.list(ascending=False, **asdict(self._filters)))
+ else:
+ configured_since = self._since or datetime.fromtimestamp(0)
+ latest_event_recorded_at = (
+ self._latest_event.recorded_at
+ if self._latest_event is not None
+ else datetime.fromtimestamp(0)
+ )
+ since = max(
+ configured_since.astimezone(),
+ latest_event_recorded_at.astimezone() - self._event_delay_tolerance,
+ )
+ self._cleanup_seen_events(before=since)
+ event_stream = EventPaginator(self._client).list(self._filters, since, ascending=True)
+
+ for event in event_stream:
+ if event.id not in self._seen_events:
+ self._seen_events[event.id] = _SeenEvent(recorded_at=event.recorded_at)
+ yield event
+ self._latest_event = event
+
+ def stream_forever(
+ self,
+ update_interval: timedelta = timedelta(seconds=LIVE_TABLE_PROVISION_INTERVAL_SECS),
+ ) -> Iterator[Event]:
+ """
+ Yields events as they are received from the server.
+ """
+
+ while True:
+ for event in self.poll():
+ yield event
+ time.sleep(update_interval.total_seconds())
+
+ def _cleanup_seen_events(self, before: datetime) -> None:
+ ids_to_delete = {
+ event_id
+ for event_id, seen_event in self._seen_events.items()
+ if seen_event.recorded_at.astimezone() < before.astimezone()
+ }
+ for event_id in ids_to_delete:
+ del self._seen_events[event_id]
+
+
+@dataclass
+class _SeenEvent:
+ recorded_at: datetime
+
+
def print_event(event: Event) -> None:
recorded_at = event.recorded_at.astimezone().strftime("%Y-%m-%d %H:%M:%S")
targets = ", ".join(f"{target.type} {target.name}" for target in event.targets)
diff --git a/src/dstack/_internal/server/routers/events.py b/src/dstack/_internal/server/routers/events.py
index 036a8b2be8..48782fb1a3 100644
--- a/src/dstack/_internal/server/routers/events.py
+++ b/src/dstack/_internal/server/routers/events.py
@@ -33,6 +33,10 @@ async def list_events(
The results are paginated. To get the next page, pass `recorded_at` and `id` of
the last event from the previous page as `prev_recorded_at` and `prev_id`.
+
+ NOTE: Some events may become available in the API with a delay after their `recorded_at`.
+ This should be taken into account when using the API to monitor recent events,
+ so that delayed events are not missed during pagination.
"""
return CustomORJSONResponse(
await events_services.list_events(
diff --git a/src/tests/_internal/cli/services/test_events.py b/src/tests/_internal/cli/services/test_events.py
new file mode 100644
index 0000000000..95aa4aaec5
--- /dev/null
+++ b/src/tests/_internal/cli/services/test_events.py
@@ -0,0 +1,349 @@
+import uuid
+from dataclasses import asdict
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+from unittest.mock import MagicMock
+
+from dstack._internal.cli.services.events import EventListFilters, EventTracker
+from dstack._internal.core.models.events import Event, EventTarget, EventTargetType
+from dstack._internal.server.schemas.events import LIST_EVENTS_DEFAULT_LIMIT
+
+
+class TestEventTracker:
+ def create_test_event(
+ self,
+ event_id: Optional[uuid.UUID] = None,
+ recorded_at: Optional[datetime] = None,
+ message: str = "Test event",
+ ) -> Event:
+ if event_id is None:
+ event_id = uuid.uuid4()
+ if recorded_at is None:
+ recorded_at = datetime.now(timezone.utc)
+
+ return Event(
+ id=event_id,
+ message=message,
+ recorded_at=recorded_at,
+ actor_user_id=uuid.uuid4(),
+ actor_user="test_user",
+ targets=[
+ EventTarget(
+ type=EventTargetType.RUN,
+ project_id=uuid.uuid4(),
+ project_name="test_project",
+ id=uuid.uuid4(),
+ name="test_run",
+ )
+ ],
+ )
+
+ def test_poll_no_since(self):
+ mock_client = MagicMock()
+ filters = EventListFilters(target_runs=[uuid.uuid4()])
+
+ tracker = EventTracker(
+ client=mock_client,
+ filters=filters,
+ since=None,
+ event_delay_tolerance=timedelta(seconds=20),
+ )
+
+ # First poll - requests latest existing events
+
+ event1 = self.create_test_event(
+ recorded_at=datetime(2023, 1, 1, 9, 0, tzinfo=timezone.utc)
+ )
+ event2 = self.create_test_event(
+ recorded_at=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc)
+ )
+ mock_client.list.return_value = [event2, event1] # reversed due to ascending=False
+
+ events = list(tracker.poll())
+
+ assert events == [event1, event2]
+ mock_client.list.assert_called_once_with(
+ ascending=False,
+ **asdict(filters),
+ )
+
+ # Second poll - requests events after the latest existing event
+
+ mock_client.list.reset_mock()
+ mock_client.list.return_value = []
+
+ events = list(tracker.poll())
+
+ assert events == []
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=event2.recorded_at - timedelta(seconds=20),
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
+
+ def test_poll_with_since(self):
+ mock_client = MagicMock()
+ filters = EventListFilters(target_runs=[uuid.uuid4()])
+
+ tracker = EventTracker(
+ client=mock_client,
+ filters=filters,
+ since=datetime(2023, 1, 1, 8, 0, tzinfo=timezone.utc),
+ event_delay_tolerance=timedelta(seconds=20),
+ )
+
+ # First poll - requests events after `since`
+
+ event1 = self.create_test_event(
+ recorded_at=datetime(2023, 1, 1, 9, 0, tzinfo=timezone.utc)
+ )
+ event2 = self.create_test_event(
+ recorded_at=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc)
+ )
+ mock_client.list.return_value = [event1, event2]
+
+ events = list(tracker.poll())
+
+ assert events == [event1, event2]
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=datetime(2023, 1, 1, 8, 0, tzinfo=timezone.utc),
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
+
+ # Second poll - requests events after the latest event
+
+ mock_client.list.reset_mock()
+ mock_client.list.return_value = []
+
+ events = list(tracker.poll())
+
+ assert events == []
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=event2.recorded_at - timedelta(seconds=20),
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
+
+ def test_poll_with_since_never_uses_prev_recorded_at_earlier_than_since(self):
+ mock_client = MagicMock()
+ filters = EventListFilters(target_runs=[uuid.uuid4()])
+ since = datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc)
+
+ tracker = EventTracker(
+ client=mock_client,
+ filters=filters,
+ since=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc),
+ event_delay_tolerance=timedelta(seconds=20),
+ )
+
+ # First poll - returns an event that is 5 seconds newer than `since`
+
+ event1 = self.create_test_event(recorded_at=since + timedelta(seconds=5))
+ mock_client.list.return_value = [event1]
+
+ events = list(tracker.poll())
+
+ assert events == [event1]
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=since,
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
+
+ # Second poll - prev_recorded_at should still be `since` (not event1 - 20s)
+
+ mock_client.list.reset_mock()
+ mock_client.list.return_value = []
+
+ events = list(tracker.poll())
+
+ assert events == []
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=since,
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
+
+ def test_poll_no_since_always_empty_response(self):
+ mock_client = MagicMock()
+ filters = EventListFilters(target_runs=[uuid.uuid4()])
+
+ tracker = EventTracker(
+ client=mock_client,
+ filters=filters,
+ since=None,
+ event_delay_tolerance=timedelta(seconds=20),
+ )
+
+ for _ in range(2):
+ mock_client.list.reset_mock()
+ mock_client.list.return_value = []
+ events = list(tracker.poll())
+ assert events == []
+ mock_client.list.assert_called_once_with(
+ ascending=False,
+ **asdict(filters),
+ )
+
+ def test_poll_with_since_always_empty_response(self):
+ mock_client = MagicMock()
+ filters = EventListFilters(target_runs=[uuid.uuid4()])
+
+ tracker = EventTracker(
+ client=mock_client,
+ filters=filters,
+ since=datetime(2023, 1, 1, 8, 0, tzinfo=timezone.utc),
+ event_delay_tolerance=timedelta(seconds=20),
+ )
+
+ for _ in range(2):
+ mock_client.list.reset_mock()
+ mock_client.list.return_value = []
+ events = list(tracker.poll())
+ assert events == []
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=datetime(2023, 1, 1, 8, 0, tzinfo=timezone.utc),
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
+
+ def test_poll_event_deduplication(self):
+ mock_client = MagicMock()
+ filters = EventListFilters(target_runs=[uuid.uuid4()])
+
+ tracker = EventTracker(
+ client=mock_client,
+ filters=filters,
+ since=datetime(2023, 1, 1, 8, 0, tzinfo=timezone.utc),
+ event_delay_tolerance=timedelta(seconds=20),
+ )
+
+ # First poll - returns event1 and event2
+
+ event1 = self.create_test_event(
+ recorded_at=datetime(2023, 1, 1, 9, 0, tzinfo=timezone.utc)
+ )
+ event2 = self.create_test_event(
+ recorded_at=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc)
+ )
+ mock_client.list.return_value = [event1, event2]
+
+ events = list(tracker.poll())
+
+ assert events == [event1, event2]
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=datetime(2023, 1, 1, 8, 0, tzinfo=timezone.utc),
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
+
+ # Second poll - returns event2 (duplicate) and event3 (new)
+
+ mock_client.list.reset_mock()
+ event3 = self.create_test_event(
+ recorded_at=datetime(2023, 1, 1, 11, 0, tzinfo=timezone.utc)
+ )
+ mock_client.list.return_value = [event2, event3]
+
+ events = list(tracker.poll())
+
+ assert events == [event3] # does not return duplicate event2
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=event2.recorded_at - timedelta(seconds=20),
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
+
+ def test_poll_respects_pagination(self):
+ mock_client = MagicMock()
+ filters = EventListFilters(target_runs=[uuid.uuid4()])
+
+ tracker = EventTracker(
+ client=mock_client,
+ filters=filters,
+ since=datetime(2023, 1, 1, 8, 0, tzinfo=timezone.utc),
+ event_delay_tolerance=timedelta(seconds=20),
+ )
+
+ ###
+ # First poll - create (1.5 * default limit) events
+ ###
+
+ num_events = int(LIST_EVENTS_DEFAULT_LIMIT * 1.5)
+ events = [
+ self.create_test_event(
+ recorded_at=datetime(2023, 1, 1, 9, 0, tzinfo=timezone.utc) + timedelta(seconds=i)
+ )
+ for i in range(num_events)
+ ]
+
+ # Mock pagination: first call returns first batch, second call returns remaining events
+ call_count = 0
+
+ def mock_list(**kwargs):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ return events[:LIST_EVENTS_DEFAULT_LIMIT] # First batch
+ elif call_count == 2:
+ return events[LIST_EVENTS_DEFAULT_LIMIT:] # Remaining events
+ else:
+ return []
+
+ mock_client.list.side_effect = mock_list
+
+ result_events = list(tracker.poll())
+
+ assert result_events == events
+ assert mock_client.list.call_count == 2
+
+ # Verify first call
+ first_call = mock_client.list.call_args_list[0]
+ assert first_call[1]["ascending"] == True
+ assert first_call[1]["prev_recorded_at"] == datetime(2023, 1, 1, 8, 0, tzinfo=timezone.utc)
+ assert first_call[1]["prev_id"] is None
+ assert first_call[1]["limit"] == LIST_EVENTS_DEFAULT_LIMIT
+
+ # Verify second call (pagination)
+ second_call = mock_client.list.call_args_list[1]
+ assert second_call[1]["ascending"] == True
+ assert (
+ second_call[1]["prev_recorded_at"] == events[LIST_EVENTS_DEFAULT_LIMIT - 1].recorded_at
+ )
+ assert second_call[1]["prev_id"] == events[LIST_EVENTS_DEFAULT_LIMIT - 1].id
+ assert second_call[1]["limit"] == LIST_EVENTS_DEFAULT_LIMIT
+
+ ###
+ # Second poll - should make one call for new events
+ ###
+
+ mock_client.reset_mock()
+ mock_client.list.return_value = []
+
+ result_events = list(tracker.poll())
+
+ assert result_events == []
+ mock_client.list.assert_called_once_with(
+ ascending=True,
+ **asdict(filters),
+ prev_recorded_at=events[-1].recorded_at - timedelta(seconds=20),
+ prev_id=None,
+ limit=LIST_EVENTS_DEFAULT_LIMIT,
+ )
From 813aed5032d314ea9c11c6079eb65a864fba6f11 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 4 Feb 2026 08:35:20 +0000
Subject: [PATCH 053/640] Add `/api/project/{project_name}/instances/get`
(#3535)
---
.../_internal/server/routers/instances.py | 22 ++++-
.../_internal/server/schemas/instances.py | 4 +
.../_internal/server/services/instances.py | 22 +++++
.../server/routers/test_instances.py | 84 +++++++++++++++++++
4 files changed, 131 insertions(+), 1 deletion(-)
diff --git a/src/dstack/_internal/server/routers/instances.py b/src/dstack/_internal/server/routers/instances.py
index 67f22d9b65..b241d7e764 100644
--- a/src/dstack/_internal/server/routers/instances.py
+++ b/src/dstack/_internal/server/routers/instances.py
@@ -1,15 +1,17 @@
-from typing import List
+from typing import Annotated, List
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
import dstack._internal.server.services.instances as instances_services
+from dstack._internal.core.errors import ResourceNotExistsError
from dstack._internal.core.models.instances import Instance
from dstack._internal.server.db import get_session
from dstack._internal.server.models import ProjectModel, UserModel
from dstack._internal.server.schemas.instances import (
GetInstanceHealthChecksRequest,
GetInstanceHealthChecksResponse,
+ GetInstanceRequest,
ListInstancesRequest,
)
from dstack._internal.server.security.permissions import Authenticated, ProjectMember
@@ -75,3 +77,21 @@ async def get_instance_health_checks(
limit=body.limit,
)
return CustomORJSONResponse(GetInstanceHealthChecksResponse(health_checks=health_checks))
+
+
+@project_router.post("/get", response_model=Instance)
+async def get_instance(
+ body: GetInstanceRequest,
+ session: Annotated[AsyncSession, Depends(get_session)],
+ user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectMember())],
+):
+ """
+ Returns an instance given its ID.
+ """
+ _, project = user_project
+ instance = await instances_services.get_instance(
+ session=session, project=project, instance_id=body.id
+ )
+ if instance is None:
+ raise ResourceNotExistsError()
+ return CustomORJSONResponse(instance)
diff --git a/src/dstack/_internal/server/schemas/instances.py b/src/dstack/_internal/server/schemas/instances.py
index 60843c6293..120ff161dc 100644
--- a/src/dstack/_internal/server/schemas/instances.py
+++ b/src/dstack/_internal/server/schemas/instances.py
@@ -7,6 +7,10 @@
from dstack._internal.server.schemas.runner import InstanceHealthResponse
+class GetInstanceRequest(CoreModel):
+ id: UUID
+
+
class ListInstancesRequest(CoreModel):
project_names: Optional[list[str]] = None
fleet_ids: Optional[list[UUID]] = None
diff --git a/src/dstack/_internal/server/services/instances.py b/src/dstack/_internal/server/services/instances.py
index 14f26cc3f0..c311df7db7 100644
--- a/src/dstack/_internal/server/services/instances.py
+++ b/src/dstack/_internal/server/services/instances.py
@@ -163,6 +163,28 @@ async def get_instance_health_checks(
return health_checks
+async def get_instance(
+ session: AsyncSession,
+ project: ProjectModel,
+ instance_id: uuid.UUID,
+) -> Optional[Instance]:
+ res = await session.execute(
+ select(InstanceModel)
+ .where(
+ InstanceModel.id == instance_id,
+ InstanceModel.project_id == project.id,
+ )
+ .options(
+ joinedload(InstanceModel.fleet).load_only(FleetModel.name),
+ joinedload(InstanceModel.project).load_only(ProjectModel.name),
+ )
+ )
+ instance_model = res.scalar_one_or_none()
+ if instance_model is None:
+ return None
+ return instance_model_to_instance(instance_model)
+
+
def instance_model_to_instance(instance_model: InstanceModel) -> Instance:
instance = Instance(
id=instance_model.id,
diff --git a/src/tests/_internal/server/routers/test_instances.py b/src/tests/_internal/server/routers/test_instances.py
index 8aee09e6d8..5f9e41df31 100644
--- a/src/tests/_internal/server/routers/test_instances.py
+++ b/src/tests/_internal/server/routers/test_instances.py
@@ -395,3 +395,87 @@ async def test_converts_legacy_termination_reason_string(
)
# Must convert legacy "Fleet has too many instances" to "max_instances_limit"
assert resp.json()[0]["termination_reason"] == "max_instances_limit"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+class TestGetInstance:
+ async def test_returns_instance_by_id(
+ self, session: AsyncSession, client: AsyncClient
+ ) -> None:
+ user = await create_user(session, global_role=GlobalRole.USER)
+ project = await create_project(session, owner=user)
+ await add_project_member(
+ session, project=project, user=user, project_role=ProjectRole.ADMIN
+ )
+ fleet = await create_fleet(session, project)
+ instance = await create_instance(session=session, project=project, fleet=fleet)
+
+ resp = await client.post(
+ f"/api/project/{project.name}/instances/get",
+ headers=get_auth_headers(user.token),
+ json={"id": str(instance.id)},
+ )
+ assert resp.status_code == 200
+ resp_data = resp.json()
+ assert resp_data["id"] == str(instance.id)
+ assert resp_data["project_name"] == project.name
+ assert resp_data["fleet_name"] == fleet.name
+
+ async def test_returns_400_if_instance_not_found(
+ self, session: AsyncSession, client: AsyncClient
+ ) -> None:
+ user = await create_user(session, global_role=GlobalRole.USER)
+ project = await create_project(session, owner=user)
+ await add_project_member(
+ session, project=project, user=user, project_role=ProjectRole.ADMIN
+ )
+
+ resp = await client.post(
+ f"/api/project/{project.name}/instances/get",
+ headers=get_auth_headers(user.token),
+ json={"id": str(uuid.uuid4())},
+ )
+ assert resp.status_code == 400
+ assert resp.json()["detail"][0]["code"] == "resource_not_exists"
+
+ async def test_returns_400_if_instance_exists_in_different_project(
+ self, session: AsyncSession, client: AsyncClient
+ ) -> None:
+ user = await create_user(session, global_role=GlobalRole.USER)
+
+ project1 = await create_project(session, owner=user, name="p1")
+ project2 = await create_project(session, owner=user, name="p2")
+
+ await add_project_member(
+ session, project=project1, user=user, project_role=ProjectRole.ADMIN
+ )
+ await add_project_member(
+ session, project=project2, user=user, project_role=ProjectRole.ADMIN
+ )
+
+ fleet = await create_fleet(session, project2)
+ instance = await create_instance(session=session, project=project2, fleet=fleet)
+
+ resp = await client.post(
+ f"/api/project/{project1.name}/instances/get",
+ headers=get_auth_headers(user.token),
+ json={"id": str(instance.id)},
+ )
+ assert resp.status_code == 400
+ assert resp.json()["detail"][0]["code"] == "resource_not_exists"
+
+ async def test_returns_403_if_not_project_member(
+ self, session: AsyncSession, client: AsyncClient
+ ) -> None:
+ user = await create_user(session, name="non_member", global_role=GlobalRole.USER)
+ project = await create_project(session)
+ fleet = await create_fleet(session, project)
+ instance = await create_instance(session=session, project=project, fleet=fleet)
+
+ resp = await client.post(
+ f"/api/project/{project.name}/instances/get",
+ headers=get_auth_headers(user.token),
+ json={"id": str(instance.id)},
+ )
+ assert resp.status_code == 403
From d01fcfd2587eb3214773380139a685d338f9069a Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 4 Feb 2026 08:35:57 +0000
Subject: [PATCH 054/640] Add run in-place update event (#3540)
---
src/dstack/_internal/core/services/diff.py | 33 +++++++++--
.../server/services/runs/__init__.py | 34 ++++++++---
.../_internal/server/services/runs/spec.py | 22 +++++--
.../_internal/core/services/test_diff.py | 59 +++++++++++++++++++
.../_internal/server/routers/test_runs.py | 12 ++++
5 files changed, 144 insertions(+), 16 deletions(-)
create mode 100644 src/tests/_internal/core/services/test_diff.py
diff --git a/src/dstack/_internal/core/services/diff.py b/src/dstack/_internal/core/services/diff.py
index 0d63cebc43..321d97f5d4 100644
--- a/src/dstack/_internal/core/services/diff.py
+++ b/src/dstack/_internal/core/services/diff.py
@@ -1,16 +1,16 @@
-from typing import Any, Optional, TypedDict, TypeVar
+from typing import Any, Optional, TypeVar, Union
from pydantic import BaseModel
-from dstack._internal.core.models.common import IncludeExcludeType
+from dstack._internal.core.models.common import CoreModel, IncludeExcludeType
-class ModelFieldDiff(TypedDict):
+class ModelFieldDiff(CoreModel):
old: Any
new: Any
-ModelDiff = dict[str, ModelFieldDiff]
+ModelDiff = dict[str, Union[ModelFieldDiff, "ModelDiff"]]
# TODO: calculate nested diffs
@@ -45,7 +45,7 @@ def diff_models(
old_value = getattr(old, field)
new_value = getattr(new, field)
if old_value != new_value:
- changes[field] = {"old": old_value, "new": new_value}
+ changes[field] = ModelFieldDiff(old=old_value, new=new_value)
return changes
@@ -69,3 +69,26 @@ def copy_model(model: M, reset: Optional[IncludeExcludeType] = None) -> M:
A deep copy of the model instance.
"""
return type(model).parse_obj(model.dict(exclude=reset))
+
+
+def flatten_diff_fields(diff: ModelDiff, prefix: str = "") -> list[str]:
+ """
+ Recursively collects all field paths from a diff.
+
+ Returns:
+ A list of field paths, each path with dot-separated parts.
+ """
+ fields = []
+ for field_name, field_diff in diff.items():
+ current_path = f"{prefix}.{field_name}" if prefix else field_name
+
+ if isinstance(field_diff, ModelFieldDiff):
+ fields.append(current_path)
+ else:
+ fields.extend(flatten_diff_fields(field_diff, current_path))
+
+ return fields
+
+
+def format_diff_fields_for_event(diff: ModelDiff) -> str:
+ return ", ".join(flatten_diff_fields(diff))
diff --git a/src/dstack/_internal/server/services/runs/__init__.py b/src/dstack/_internal/server/services/runs/__init__.py
index f99934dc3f..5ae19b348f 100644
--- a/src/dstack/_internal/server/services/runs/__init__.py
+++ b/src/dstack/_internal/server/services/runs/__init__.py
@@ -37,6 +37,7 @@
RunTerminationReason,
ServiceSpec,
)
+from dstack._internal.core.services.diff import format_diff_fields_for_event
from dstack._internal.server.db import get_db, is_db_postgres, is_db_sqlite
from dstack._internal.server.models import (
FleetModel,
@@ -258,11 +259,11 @@ async def get_run(
raise ServerClientError("run_name or id must be specified")
-async def get_run_by_name(
+async def get_run_model_by_name(
session: AsyncSession,
project: ProjectModel,
run_name: str,
-) -> Optional[Run]:
+) -> Optional[RunModel]:
res = await session.execute(
select(RunModel)
.where(
@@ -274,7 +275,15 @@ async def get_run_by_name(
.options(joinedload(RunModel.fleet).load_only(FleetModel.id, FleetModel.name))
.options(selectinload(RunModel.jobs).joinedload(JobModel.probes))
)
- run_model = res.scalar()
+ return res.scalar()
+
+
+async def get_run_by_name(
+ session: AsyncSession,
+ project: ProjectModel,
+ run_name: str,
+) -> Optional[Run]:
+ run_model = await get_run_model_by_name(session=session, project=project, run_name=run_name)
if run_model is None:
return None
return run_model_to_run(run_model, return_in_api=True)
@@ -386,24 +395,25 @@ async def apply_plan(
project=project,
run_spec=run_spec,
)
- current_resource = await get_run_by_name(
+ current_resource_model = await get_run_model_by_name(
session=session,
project=project,
run_name=run_spec.run_name,
)
- if current_resource is None or current_resource.status.is_finished():
+ if current_resource_model is None or current_resource_model.status.is_finished():
return await submit_run(
session=session,
user=user,
project=project,
run_spec=run_spec,
)
+ current_resource = run_model_to_run(current_resource_model, return_in_api=True)
# For backward compatibility (current_resource may has been submitted before
# some fields, e.g., CPUSpec.arch, were added)
set_resources_defaults(current_resource.run_spec.configuration.resources)
try:
- check_can_update_run_spec(current_resource.run_spec, run_spec)
+ spec_diff = check_can_update_run_spec(current_resource.run_spec, run_spec)
except ServerClientError:
# The except is only needed to raise an appropriate error if run is active
if not current_resource.status.is_finished():
@@ -420,6 +430,7 @@ async def apply_plan(
raise ServerClientError(
"Failed to apply plan. Resource has been changed. Try again or use force apply."
)
+ new_deployment_num = current_resource.deployment_num + 1
# FIXME: potentially long write transaction
# Avoid getting run_model after update
await session.execute(
@@ -428,9 +439,18 @@ async def apply_plan(
.values(
run_spec=run_spec.json(),
priority=run_spec.configuration.priority,
- deployment_num=current_resource.deployment_num + 1,
+ deployment_num=new_deployment_num,
)
)
+ events.emit(
+ session,
+ (
+ f"Run updated. Deployment: {new_deployment_num}."
+ f" Changed fields: {format_diff_fields_for_event(spec_diff)}"
+ ),
+ actor=events.UserActor.from_user(user),
+ targets=[events.Target.from_model(current_resource_model)],
+ )
run = await get_run_by_name(
session=session,
project=project,
diff --git a/src/dstack/_internal/server/services/runs/spec.py b/src/dstack/_internal/server/services/runs/spec.py
index ad2fcef1ff..f478d187bb 100644
--- a/src/dstack/_internal/server/services/runs/spec.py
+++ b/src/dstack/_internal/server/services/runs/spec.py
@@ -4,7 +4,7 @@
from dstack._internal.core.models.runs import LEGACY_REPO_DIR, AnyRunConfiguration, RunSpec
from dstack._internal.core.models.volumes import InstanceMountPoint
from dstack._internal.core.services import validate_dstack_resource_name
-from dstack._internal.core.services.diff import diff_models
+from dstack._internal.core.services.diff import ModelDiff, diff_models
from dstack._internal.server import settings
from dstack._internal.server.models import UserModel
from dstack._internal.server.services.docker import is_valid_docker_volume_target
@@ -117,7 +117,13 @@ def validate_run_spec_and_set_defaults(
run_spec.configuration.working_dir = LEGACY_REPO_DIR
-def check_can_update_run_spec(current_run_spec: RunSpec, new_run_spec: RunSpec):
+def check_can_update_run_spec(current_run_spec: RunSpec, new_run_spec: RunSpec) -> ModelDiff:
+ """
+ Check if in-place update is possible.
+
+ Returns the diff if it is possible.
+ Raises ServerClientError otherwise.
+ """
spec_diff = diff_models(current_run_spec, new_run_spec)
changed_spec_fields = list(spec_diff.keys())
updatable_spec_fields = _UPDATABLE_SPEC_FIELDS + _TYPE_SPECIFIC_UPDATABLE_SPEC_FIELDS.get(
@@ -133,9 +139,10 @@ def check_can_update_run_spec(current_run_spec: RunSpec, new_run_spec: RunSpec):
# are the same (the same id => hash => content and the same container path), the order of
# unpacking matters when one path is a subpath of another.
ignore_files = current_run_spec.file_archives == new_run_spec.file_archives
- _check_can_update_configuration(
+ spec_diff["configuration"] = _check_can_update_configuration(
current_run_spec.configuration, new_run_spec.configuration, ignore_files
)
+ return spec_diff
def can_update_run_spec(current_run_spec: RunSpec, new_run_spec: RunSpec) -> bool:
@@ -167,7 +174,13 @@ def check_run_spec_requires_instance_mounts(run_spec: RunSpec) -> bool:
def _check_can_update_configuration(
current: AnyRunConfiguration, new: AnyRunConfiguration, ignore_files: bool
-) -> None:
+) -> ModelDiff:
+ """
+ Check if in-place update is possible.
+
+ Returns the diff if it is possible.
+ Raises ServerClientError otherwise.
+ """
if current.type != new.type:
raise ServerClientError(
f"Configuration type changed from {current.type} to {new.type}, cannot update"
@@ -189,3 +202,4 @@ def _check_can_update_configuration(
raise ServerClientError(
f"Failed to update fields {changed_fields}. Can only update {updatable_fields}"
)
+ return diff
diff --git a/src/tests/_internal/core/services/test_diff.py b/src/tests/_internal/core/services/test_diff.py
new file mode 100644
index 0000000000..4e8d355c0d
--- /dev/null
+++ b/src/tests/_internal/core/services/test_diff.py
@@ -0,0 +1,59 @@
+import pytest
+
+from dstack._internal.core.services.diff import ModelDiff, ModelFieldDiff, flatten_diff_fields
+
+
+@pytest.mark.parametrize(
+ "diff,expected",
+ [
+ pytest.param({}, [], id="empty_diff"),
+ pytest.param(
+ {
+ "field1": ModelFieldDiff(old="old1", new="new1"),
+ "field2": ModelFieldDiff(old="old2", new="new2"),
+ },
+ ["field1", "field2"],
+ id="multiple_fields",
+ ),
+ pytest.param(
+ {
+ "field1": ModelFieldDiff(old="old1", new="new1"),
+ "nested": {
+ "sub1": ModelFieldDiff(old="old_sub1", new="new_sub1"),
+ },
+ },
+ ["field1", "nested.sub1"],
+ id="nested_single_level",
+ ),
+ pytest.param(
+ {
+ "field1": ModelFieldDiff(old="old1", new="new1"),
+ "level1": {
+ "level2": {
+ "level3": {"deep_field": ModelFieldDiff(old="deep_old", new="deep_new")},
+ "field2": ModelFieldDiff(old="old2", new="new2"),
+ },
+ "field3": ModelFieldDiff(old="old3", new="new3"),
+ },
+ },
+ ["field1", "level1.level2.level3.deep_field", "level1.level2.field2", "level1.field3"],
+ id="nested_multiple_levels",
+ ),
+ pytest.param(
+ {
+ "field1": ModelFieldDiff(old="old1", new="new1"),
+ "empty_nested": {},
+ "nested_with_empty": {
+ "empty_sub": {},
+ "field2": ModelFieldDiff(old="old2", new="new2"),
+ },
+ },
+ ["field1", "nested_with_empty.field2"],
+ id="empty_nested",
+ ),
+ ],
+)
+def test_flatten_diff_fields(diff: ModelDiff, expected: list[str]):
+ """Test flatten_diff_fields with various diff structures."""
+ result = flatten_diff_fields(diff)
+ assert result == expected
diff --git a/src/tests/_internal/server/routers/test_runs.py b/src/tests/_internal/server/routers/test_runs.py
index d24382f35e..35a251b2cf 100644
--- a/src/tests/_internal/server/routers/test_runs.py
+++ b/src/tests/_internal/server/routers/test_runs.py
@@ -48,6 +48,7 @@
from dstack._internal.server.schemas.runs import ApplyRunPlanRequest
from dstack._internal.server.services.projects import add_project_member
from dstack._internal.server.services.runs import run_model_to_run
+from dstack._internal.server.services.runs.spec import validate_run_spec_and_set_defaults
from dstack._internal.server.testing.common import (
create_backend,
create_fleet,
@@ -1549,6 +1550,7 @@ async def test_updates_run(self, test_db, session: AsyncSession, client: AsyncCl
repo = await create_repo(session=session, project_id=project.id)
run_spec = get_run_spec(
run_name="test-service",
+ configuration_path="old.dstack.yml",
repo_id=repo.name,
configuration=ServiceConfiguration(
type="service",
@@ -1557,6 +1559,8 @@ async def test_updates_run(self, test_db, session: AsyncSession, client: AsyncCl
replicas=Range(min=1, max=1),
),
)
+ # set defaults to avoid phantom changes being detected
+ validate_run_spec_and_set_defaults(user, run_spec)
run_model = await create_run(
session=session,
project=project,
@@ -1566,6 +1570,7 @@ async def test_updates_run(self, test_db, session: AsyncSession, client: AsyncCl
run_spec=run_spec,
)
run = run_model_to_run(run_model)
+ run_spec.configuration_path = "new.dstack.yml"
run_spec.configuration.replicas = Range(min=2, max=2)
response = await client.post(
f"/api/project/{project.name}/runs/apply",
@@ -1586,8 +1591,15 @@ async def test_updates_run(self, test_db, session: AsyncSession, client: AsyncCl
updated_run = run_model_to_run(run_model)
assert run.deployment_num == 0
assert updated_run.deployment_num == 1
+ assert run.run_spec.configuration_path == "old.dstack.yml"
+ assert updated_run.run_spec.configuration_path == "new.dstack.yml"
assert run.run_spec.configuration.replicas == Range(min=1, max=1)
assert updated_run.run_spec.configuration.replicas == Range(min=2, max=2)
+ events = await list_events(session)
+ assert len(events) == 1
+ assert events[0].message == (
+ "Run updated. Deployment: 1. Changed fields: configuration_path, configuration.replicas"
+ )
@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
From 1b65d8f0eeca8b9ec3ccd8178739bfb9708263ec Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 4 Feb 2026 08:36:19 +0000
Subject: [PATCH 055/640] Add job in-place update event (#3541)
---
.../_internal/server/background/tasks/process_runs.py | 7 +++++++
.../server/background/tasks/test_process_runs.py | 10 ++++++++++
2 files changed, 17 insertions(+)
diff --git a/src/dstack/_internal/server/background/tasks/process_runs.py b/src/dstack/_internal/server/background/tasks/process_runs.py
index ee907e519b..e9421e5cbd 100644
--- a/src/dstack/_internal/server/background/tasks/process_runs.py
+++ b/src/dstack/_internal/server/background/tasks/process_runs.py
@@ -30,6 +30,7 @@
RunModel,
UserModel,
)
+from dstack._internal.server.services import events
from dstack._internal.server.services.jobs import (
find_job,
get_job_specs_from_run_spec,
@@ -661,6 +662,12 @@ async def _update_jobs_to_new_deployment_in_place(
if can_update_all_jobs:
for job_model in job_models:
job_model.deployment_num = run_model.deployment_num
+ events.emit(
+ session,
+ f"Job updated. Deployment: {job_model.deployment_num}",
+ actor=events.SystemActor(),
+ targets=[events.Target.from_model(job_model)],
+ )
async def _should_retry_job(
diff --git a/src/tests/_internal/server/background/tasks/test_process_runs.py b/src/tests/_internal/server/background/tasks/test_process_runs.py
index 46aaa9b48e..b9420d8e9a 100644
--- a/src/tests/_internal/server/background/tasks/test_process_runs.py
+++ b/src/tests/_internal/server/background/tasks/test_process_runs.py
@@ -35,6 +35,7 @@
create_user,
get_job_provisioning_data,
get_run_spec,
+ list_events,
)
from dstack._internal.utils import common
@@ -666,6 +667,10 @@ async def test_updates_deployment_num_in_place(
assert run.jobs[1].status == job_statuses[1]
assert run.jobs[1].replica_num == 1
assert run.jobs[1].deployment_num == 1 # updated
+ events = await list_events(session)
+ assert len(events) == 2
+ assert events[0].message == "Job updated. Deployment: 1"
+ assert events[1].message == "Job updated. Deployment: 1"
async def test_not_updates_deployment_num_in_place_for_finished_replica(
self, test_db, session: AsyncSession
@@ -697,6 +702,11 @@ async def test_not_updates_deployment_num_in_place_for_finished_replica(
assert run.jobs[1].status == JobStatus.TERMINATED
assert run.jobs[1].replica_num == 1
assert run.jobs[1].deployment_num == 0 # not updated
+ events = await list_events(session)
+ assert len(events) == 1
+ assert events[0].message == "Job updated. Deployment: 1"
+ assert len(events[0].targets) == 1
+ assert events[0].targets[0].entity_id == run.jobs[0].id
async def test_starts_new_replica(self, test_db, session: AsyncSession) -> None:
run = await make_run(session, status=RunStatus.RUNNING, replicas=2, image="old")
From 93bb51c7a7e5ee13e5acd27b1b1f4e89c663639c Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Wed, 4 Feb 2026 11:02:54 +0000
Subject: [PATCH 056/640] Add probe `until_ready` configuration option (#3530)
This option allows to stop executing the probe
once it reaches the `ready_after` threshold of
successful executions. This is useful for heavier
probes that only need to run in the startup phase
and not during regular replica operation.
---
.../_internal/core/compatibility/runs.py | 20 ++++--
.../_internal/core/models/configurations.py | 11 ++++
src/dstack/_internal/core/models/runs.py | 2 +
.../server/background/tasks/process_probes.py | 15 +++--
.../services/jobs/configurators/base.py | 2 +
.../background/tasks/test_process_probes.py | 61 +++++++++++++++++++
6 files changed, 99 insertions(+), 12 deletions(-)
diff --git a/src/dstack/_internal/core/compatibility/runs.py b/src/dstack/_internal/core/compatibility/runs.py
index f1b7dc5222..90ef9d98a8 100644
--- a/src/dstack/_internal/core/compatibility/runs.py
+++ b/src/dstack/_internal/core/compatibility/runs.py
@@ -1,7 +1,9 @@
from typing import Optional
from dstack._internal.core.models.common import IncludeExcludeDictType, IncludeExcludeSetType
+from dstack._internal.core.models.configurations import ServiceConfiguration
from dstack._internal.core.models.runs import (
+ DEFAULT_PROBE_UNTIL_READY,
DEFAULT_REPLICA_GROUP_NAME,
ApplyRunPlanInput,
JobSpec,
@@ -60,12 +62,12 @@ def get_run_spec_excludes(run_spec: RunSpec) -> IncludeExcludeDictType:
configuration_excludes: IncludeExcludeDictType = {}
profile_excludes: IncludeExcludeSetType = set()
- # Add excludes like this:
- #
- # if run_spec.configuration.tags is None:
- # configuration_excludes["tags"] = True
- # if run_spec.profile is not None and run_spec.profile.tags is None:
- # profile_excludes.add("tags")
+ if isinstance(run_spec.configuration, ServiceConfiguration):
+ if run_spec.configuration.probes:
+ probe_excludes: IncludeExcludeDictType = {}
+ configuration_excludes["probes"] = {"__all__": probe_excludes}
+ if all(p.until_ready is None for p in run_spec.configuration.probes):
+ probe_excludes["until_ready"] = True
if configuration_excludes:
spec_excludes["configuration"] = configuration_excludes
@@ -83,4 +85,10 @@ def get_job_spec_excludes(job_specs: list[JobSpec]) -> IncludeExcludeDictType:
spec_excludes: IncludeExcludeDictType = {}
if all(s.replica_group == DEFAULT_REPLICA_GROUP_NAME for s in job_specs):
spec_excludes["replica_group"] = True
+
+ probe_excludes: IncludeExcludeDictType = {}
+ spec_excludes["probes"] = {"__all__": probe_excludes}
+ if all(all(p.until_ready == DEFAULT_PROBE_UNTIL_READY for p in s.probes) for s in job_specs):
+ probe_excludes["until_ready"] = True
+
return spec_excludes
diff --git a/src/dstack/_internal/core/models/configurations.py b/src/dstack/_internal/core/models/configurations.py
index 465df261b1..9c8b40b6ec 100644
--- a/src/dstack/_internal/core/models/configurations.py
+++ b/src/dstack/_internal/core/models/configurations.py
@@ -54,6 +54,7 @@
DEFAULT_PROBE_INTERVAL = 15
DEFAULT_PROBE_READY_AFTER = 1
DEFAULT_PROBE_METHOD = "get"
+DEFAULT_PROBE_UNTIL_READY = False
MAX_PROBE_URL_LEN = 2048
DEFAULT_REPLICA_GROUP_NAME = "0"
DEFAULT_MODEL_PROBE_TIMEOUT = 30
@@ -374,6 +375,16 @@ class ProbeConfig(generate_dual_core_model(ProbeConfigConfig)):
),
),
] = None
+ until_ready: Annotated[
+ Optional[bool],
+ Field(
+ description=(
+ "If `true`, the probe will stop being executed as soon as it reaches the"
+ " `ready_after` threshold of successful executions."
+ f" Defaults to `{str(DEFAULT_PROBE_UNTIL_READY).lower()}`"
+ ),
+ ),
+ ] = None
@validator("timeout", pre=True)
def parse_timeout(cls, v: Optional[Union[int, str]]) -> Optional[int]:
diff --git a/src/dstack/_internal/core/models/runs.py b/src/dstack/_internal/core/models/runs.py
index 27c6f430c1..817dd4c497 100644
--- a/src/dstack/_internal/core/models/runs.py
+++ b/src/dstack/_internal/core/models/runs.py
@@ -17,6 +17,7 @@
)
from dstack._internal.core.models.configurations import (
DEFAULT_PROBE_METHOD,
+ DEFAULT_PROBE_UNTIL_READY,
DEFAULT_REPLICA_GROUP_NAME,
LEGACY_REPO_DIR,
AnyRunConfiguration,
@@ -247,6 +248,7 @@ class ProbeSpec(CoreModel):
timeout: int
interval: int
ready_after: int
+ until_ready: bool = DEFAULT_PROBE_UNTIL_READY
class JobSpec(CoreModel):
diff --git a/src/dstack/_internal/server/background/tasks/process_probes.py b/src/dstack/_internal/server/background/tasks/process_probes.py
index bc1dc09431..4f712ff4cb 100644
--- a/src/dstack/_internal/server/background/tasks/process_probes.py
+++ b/src/dstack/_internal/server/background/tasks/process_probes.py
@@ -73,12 +73,15 @@ async def process_probes():
else:
job_spec: JobSpec = JobSpec.__response__.parse_raw(probe.job.job_spec_data)
probe_spec = job_spec.probes[probe.probe_num]
- # Schedule the next probe execution in case this execution is interrupted
- probe.due = get_current_datetime() + _get_probe_async_processing_timeout(
- probe_spec
- )
- # Execute the probe asynchronously outside of the DB session
- PROBES_SCHEDULER.add_job(partial(_process_probe_async, probe, probe_spec))
+ if probe_spec.until_ready and probe.success_streak >= probe_spec.ready_after:
+ probe.active = False
+ else:
+ # Schedule the next probe execution in case this execution is interrupted
+ probe.due = get_current_datetime() + _get_probe_async_processing_timeout(
+ probe_spec
+ )
+ # Execute the probe asynchronously outside of the DB session
+ PROBES_SCHEDULER.add_job(partial(_process_probe_async, probe, probe_spec))
await session.commit()
finally:
probe_lockset.difference_update(probe_ids)
diff --git a/src/dstack/_internal/server/services/jobs/configurators/base.py b/src/dstack/_internal/server/services/jobs/configurators/base.py
index 8e3d2374d7..3b6038ccd9 100644
--- a/src/dstack/_internal/server/services/jobs/configurators/base.py
+++ b/src/dstack/_internal/server/services/jobs/configurators/base.py
@@ -18,6 +18,7 @@
DEFAULT_PROBE_METHOD,
DEFAULT_PROBE_READY_AFTER,
DEFAULT_PROBE_TIMEOUT,
+ DEFAULT_PROBE_UNTIL_READY,
DEFAULT_PROBE_URL,
DEFAULT_REPLICA_GROUP_NAME,
LEGACY_REPO_DIR,
@@ -455,6 +456,7 @@ def _probe_config_to_spec(c: ProbeConfig) -> ProbeSpec:
method=c.method if c.method is not None else DEFAULT_PROBE_METHOD,
headers=c.headers,
body=c.body,
+ until_ready=c.until_ready if c.until_ready is not None else DEFAULT_PROBE_UNTIL_READY,
)
diff --git a/src/tests/_internal/server/background/tasks/test_process_probes.py b/src/tests/_internal/server/background/tasks/test_process_probes.py
index ebc3c15b97..928709dd7f 100644
--- a/src/tests/_internal/server/background/tasks/test_process_probes.py
+++ b/src/tests/_internal/server/background/tasks/test_process_probes.py
@@ -163,6 +163,67 @@ async def test_schedules_probe_execution(self, test_db, session: AsyncSession) -
+ PROCESSING_OVERHEAD_TIMEOUT
)
+ async def test_deactivates_probe_when_until_ready_and_ready_after_reached(
+ self, test_db, session: AsyncSession
+ ) -> None:
+ project = await create_project(session=session)
+ user = await create_user(session=session)
+ repo = await create_repo(
+ session=session,
+ project_id=project.id,
+ )
+ run = await create_run(
+ session=session,
+ project=project,
+ repo=repo,
+ user=user,
+ run_spec=get_run_spec(
+ run_name="test",
+ repo_id=repo.name,
+ configuration=ServiceConfiguration(
+ port=80,
+ image="nginx",
+ probes=[
+ ProbeConfig(
+ type="http", url="/until_ready", until_ready=True, ready_after=3
+ ),
+ ProbeConfig(type="http", url="/regular", until_ready=False, ready_after=3),
+ ],
+ ),
+ ),
+ )
+ instance = await create_instance(
+ session=session,
+ project=project,
+ status=InstanceStatus.BUSY,
+ )
+ job = await create_job(
+ session=session,
+ run=run,
+ status=JobStatus.RUNNING,
+ job_provisioning_data=get_job_provisioning_data(),
+ instance=instance,
+ instance_assigned=True,
+ )
+
+ probe_until_ready = await create_probe(session, job, probe_num=0, success_streak=3)
+ probe_regular = await create_probe(session, job, probe_num=1, success_streak=3)
+
+ with patch(
+ "dstack._internal.server.background.tasks.process_probes.PROBES_SCHEDULER"
+ ) as scheduler_mock:
+ await process_probes()
+
+ await session.refresh(probe_until_ready)
+ await session.refresh(probe_regular)
+
+ assert not probe_until_ready.active
+ assert probe_until_ready.success_streak == 3
+
+ assert probe_regular.active
+ assert probe_regular.success_streak == 3
+ assert scheduler_mock.add_job.call_count == 1 # only the regular probe was scheduled
+
# TODO: test probe success and failure
# (skipping for now - a bit difficult to test and most of the logic will be mocked)
From 5ccbf0f8de61b83a92d1423c341cfc9cef907b6f Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Wed, 4 Feb 2026 12:06:43 +0100
Subject: [PATCH 057/640] CLI crashes with 'Operation not permitted' when log
file is not writable (#3538)
* CLI crashes with 'Operation not permitted' when log file is not writable #3537
* Handling the exception properly; plus improved the error message
---
src/dstack/_internal/cli/utils/common.py | 22 +++++++++++++---------
1 file changed, 13 insertions(+), 9 deletions(-)
diff --git a/src/dstack/_internal/cli/utils/common.py b/src/dstack/_internal/cli/utils/common.py
index afbfb2d8cb..dfd7b98e48 100644
--- a/src/dstack/_internal/cli/utils/common.py
+++ b/src/dstack/_internal/cli/utils/common.py
@@ -77,21 +77,25 @@ def configure_logging():
dstack_logger = logging.getLogger("dstack")
dstack_logger.handlers.clear()
- log_file = _get_cli_log_file()
-
stdout_handler = DstackRichHandler(console=console)
stdout_handler.setFormatter(logging.Formatter(fmt="%(message)s", datefmt="[%X]"))
stdout_handler.setLevel(settings.CLI_LOG_LEVEL)
dstack_logger.addHandler(stdout_handler)
- file_handler = logging.FileHandler(log_file)
- file_handler.setFormatter(
- logging.Formatter(
- fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
+ log_file = get_dstack_dir() / "logs" / "cli" / "latest.log"
+ try:
+ log_file = _get_cli_log_file()
+ file_handler = logging.FileHandler(log_file)
+ file_handler.setFormatter(
+ logging.Formatter(
+ fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
)
- )
- file_handler.setLevel(settings.CLI_FILE_LOG_LEVEL)
- dstack_logger.addHandler(file_handler)
+ file_handler.setLevel(settings.CLI_FILE_LOG_LEVEL)
+ dstack_logger.addHandler(file_handler)
+ except PermissionError:
+ console.print(f"[warning]Couldn't write to {log_file} due to a permissions problem.[/]")
# the logger allows all messages, filtering is done by the handlers
dstack_logger.setLevel(logging.DEBUG)
From b29367286b89862687e75d7314a0ed41aab14d54 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Wed, 4 Feb 2026 12:16:28 +0100
Subject: [PATCH 058/640] [Docs] Unlisted `cudo` backend (#3539)
---
docs/docs/concepts/backends.md | 21 ---------------------
docs/docs/concepts/dev-environments.md | 2 +-
docs/docs/reference/server/config.yml.md | 17 -----------------
docs/partners.md | 14 --------------
4 files changed, 1 insertion(+), 53 deletions(-)
diff --git a/docs/docs/concepts/backends.md b/docs/docs/concepts/backends.md
index d747dffd14..2ed49bda37 100644
--- a/docs/docs/concepts/backends.md
+++ b/docs/docs/concepts/backends.md
@@ -765,27 +765,6 @@ projects:
-### CUDO
-
-Log into your [CUDO Compute](https://compute.cudo.org/) account, click API keys in the sidebar, and click the `Create an API key` button.
-
-Ensure you've created a project with CUDO Compute, then proceed to configuring the backend.
-
-
-
-```yaml
-projects:
- - name: main
- backends:
- - type: cudo
- project_id: my-cudo-project
- creds:
- type: api_key
- api_key: 7487240a466624b48de22865589
-```
-
-
-
### OCI
There are two ways to configure OCI: using client credentials or using the default credentials.
diff --git a/docs/docs/concepts/dev-environments.md b/docs/docs/concepts/dev-environments.md
index 81781d49af..d946e539ba 100644
--- a/docs/docs/concepts/dev-environments.md
+++ b/docs/docs/concepts/dev-environments.md
@@ -513,7 +513,7 @@ it has been inactive in `dstack ps --verbose` (or `-v`).
```shell
$ dstack ps -v
NAME BACKEND RESOURCES PRICE STATUS SUBMITTED
- vscode cudo 2xCPU, 8GB, $0.0286 running 8 mins ago
+ vscode runpod 2xCPU, 8GB, $0.0286 running 8 mins ago
100.0GB (disk) (inactive for 2m 34s)
```
diff --git a/docs/docs/reference/server/config.yml.md b/docs/docs/reference/server/config.yml.md
index 90283eb4de..bf1ab46473 100644
--- a/docs/docs/reference/server/config.yml.md
+++ b/docs/docs/reference/server/config.yml.md
@@ -241,23 +241,6 @@ to configure [backends](../../concepts/backends.md) and other [server-level sett
type:
required: true
-##### `projects[n].backends[type=cudo]` { #cudo data-toc-label="cudo" }
-
-#SCHEMA# dstack._internal.core.backends.cudo.models.CudoBackendConfigWithCreds
- overrides:
- show_root_heading: false
- type:
- required: true
- item_id_prefix: cudo-
-
-###### `projects[n].backends[type=cudo].creds` { #cudo-creds data-toc-label="creds" }
-
-#SCHEMA# dstack._internal.core.backends.cudo.models.CudoAPIKeyCreds
- overrides:
- show_root_heading: false
- type:
- required: true
-
##### `projects[n].backends[type=verda]` { #verda data-toc-label="verda" }
#SCHEMA# dstack._internal.core.backends.verda.models.VerdaBackendConfigWithCreds
diff --git a/docs/partners.md b/docs/partners.md
index a863fd6a8e..44295691a8 100644
--- a/docs/partners.md
+++ b/docs/partners.md
@@ -80,20 +80,6 @@ hide:
-
-
-
- CUDO
-
-
-
-
-
From ebfd8952c3609cdd815292dc2d94f5e71d472a9f Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Wed, 4 Feb 2026 12:17:43 +0100
Subject: [PATCH 059/640] [UX] Improve `dstack fleet` output layout (#3529)
* [UX] Make `dstack fleet` show more useful information
* Do not show "(no instances" row placeholder for empty fleets; removed unecessary docstrings and comments.
* Linter and pyright
* PR review feedback
---
src/dstack/_internal/cli/utils/fleet.py | 338 ++++++++++---
src/tests/_internal/cli/utils/test_fleet.py | 502 ++++++++++++++++++++
2 files changed, 762 insertions(+), 78 deletions(-)
create mode 100644 src/tests/_internal/cli/utils/test_fleet.py
diff --git a/src/dstack/_internal/cli/utils/fleet.py b/src/dstack/_internal/cli/utils/fleet.py
index 3d04100c8a..fdca4270ab 100644
--- a/src/dstack/_internal/cli/utils/fleet.py
+++ b/src/dstack/_internal/cli/utils/fleet.py
@@ -1,11 +1,12 @@
-from typing import List
+from typing import Any, Dict, List, Optional, Union
from rich.table import Table
from dstack._internal.cli.utils.common import add_row_from_dict, console
from dstack._internal.core.models.backends.base import BackendType
-from dstack._internal.core.models.fleets import Fleet, FleetStatus
-from dstack._internal.core.models.instances import InstanceStatus
+from dstack._internal.core.models.fleets import Fleet, FleetNodesSpec, FleetStatus
+from dstack._internal.core.models.instances import Instance, InstanceStatus
+from dstack._internal.core.models.resources import GPUSpec, ResourcesSpec
from dstack._internal.utils.common import DateFormatter, pretty_date
@@ -18,89 +19,270 @@ def get_fleets_table(
fleets: List[Fleet], verbose: bool = False, format_date: DateFormatter = pretty_date
) -> Table:
table = Table(box=None)
- table.add_column("FLEET", no_wrap=True)
+
+ # Columns
+ table.add_column("NAME", style="bold", no_wrap=True)
+ table.add_column("NODES")
if verbose:
- table.add_column("RESERVATION")
- table.add_column("INSTANCE")
+ table.add_column("RESOURCES")
+ else:
+ table.add_column("GPU")
+ table.add_column("SPOT")
table.add_column("BACKEND")
- if verbose:
- table.add_column("REGION")
- table.add_column("RESOURCES")
table.add_column("PRICE")
- table.add_column("STATUS")
- table.add_column("CREATED")
-
+ table.add_column("STATUS", no_wrap=True)
+ table.add_column("CREATED", no_wrap=True)
if verbose:
table.add_column("ERROR")
for fleet in fleets:
- for i, instance in enumerate(fleet.instances):
- resources = ""
- if instance.instance_type is not None and (
- instance.backend != BackendType.REMOTE
- or instance.status not in [InstanceStatus.PENDING, InstanceStatus.PROVISIONING]
- ):
- resources = instance.instance_type.resources.pretty_format(include_spot=True)
-
- status = instance.status.value
- total_blocks = instance.total_blocks
- busy_blocks = instance.busy_blocks
- if (
- instance.status in [InstanceStatus.IDLE, InstanceStatus.BUSY]
- and total_blocks is not None
- and total_blocks > 1
- ):
- status = f"{busy_blocks}/{total_blocks} {InstanceStatus.BUSY.value}"
- if instance.status in [InstanceStatus.IDLE, InstanceStatus.BUSY]:
- if instance.unreachable:
- status += "\n(unreachable)"
- elif not instance.health_status.is_healthy():
- status += f"\n({instance.health_status.value})"
-
- backend = instance.backend or ""
- if backend == "remote":
- backend = "ssh"
-
- region = ""
- if instance.region:
- region = f"{instance.region}"
- if verbose:
- if instance.availability_zone:
- region += f" ({instance.availability_zone})"
- else:
- backend += f" ({instance.region})"
- error = ""
- if instance.status == InstanceStatus.TERMINATED and instance.termination_reason:
- error = f"{instance.termination_reason}"
- row = {
- "FLEET": fleet.name if i == 0 else "",
- "RESERVATION": fleet.spec.configuration.reservation or "" if i == 0 else "",
- "INSTANCE": str(instance.instance_num),
- "BACKEND": backend,
- "REGION": region,
- "RESOURCES": resources,
- "PRICE": f"${instance.price:.4f}".rstrip("0").rstrip(".")
- if instance.price is not None
- else "",
- "STATUS": status,
+ # Fleet row
+ config = fleet.spec.configuration
+ merged_profile = fleet.spec.merged_profile
+
+ # Detect SSH fleet vs backend fleet
+ if config.ssh_config is not None:
+ # SSH fleet: fixed number of hosts, no cloud billing
+ nodes = str(len(config.ssh_config.hosts))
+ backend = "ssh"
+ spot_policy = "-"
+ max_price = "-"
+ else:
+ # Backend fleet: dynamic nodes, cloud billing
+ nodes = _format_nodes(config.nodes)
+ backend = _format_backends(config.backends)
+ spot_policy = "-"
+ if merged_profile and merged_profile.spot_policy:
+ spot_policy = merged_profile.spot_policy.value
+ # Format as "$0..$X.XX" range, or "-" if not set
+ if merged_profile and merged_profile.max_price is not None:
+ max_price = f"$0..{_format_price(merged_profile.max_price)}"
+ else:
+ max_price = "-"
+
+ # In verbose mode, append placement to nodes if cluster
+ if verbose and config.placement and config.placement.value == "cluster":
+ nodes = f"{nodes} (cluster)"
+
+ fleet_row: Dict[Union[str, int], Any] = {
+ "NAME": fleet.name,
+ "NODES": nodes,
+ "BACKEND": backend,
+ "PRICE": max_price,
+ "SPOT": spot_policy,
+ "STATUS": _format_fleet_status(fleet),
+ "CREATED": format_date(fleet.created_at),
+ }
+
+ if verbose:
+ fleet_row["RESOURCES"] = config.resources.pretty_format() if config.resources else "-"
+ fleet_row["ERROR"] = ""
+ else:
+ fleet_row["GPU"] = _format_fleet_gpu(config.resources)
+
+ add_row_from_dict(table, fleet_row)
+
+ # Instance rows (indented)
+ for instance in fleet.instances:
+ # Check if this is an SSH instance
+ is_ssh_instance = instance.backend == BackendType.REMOTE
+
+ # Format backend with region (and AZ in verbose mode)
+ if verbose and instance.availability_zone:
+ # In verbose mode, show AZ instead of region (AZ is more specific)
+ backend_with_region = _format_backend(instance.backend, instance.availability_zone)
+ else:
+ backend_with_region = _format_backend(instance.backend, instance.region)
+
+ # Get spot info from instance resources (not applicable to SSH)
+ if is_ssh_instance:
+ instance_spot = "-"
+ instance_price = "-"
+ else:
+ instance_spot = "-"
+ if (
+ instance.instance_type is not None
+ and instance.instance_type.resources is not None
+ ):
+ instance_spot = (
+ "spot" if instance.instance_type.resources.spot else "on-demand"
+ )
+ instance_price = _format_price(instance.price)
+
+ instance_row: Dict[Union[str, int], Any] = {
+ "NAME": f" instance={instance.instance_num}",
+ "NODES": "",
+ "BACKEND": backend_with_region,
+ "PRICE": instance_price,
+ "SPOT": instance_spot,
+ "STATUS": _format_instance_status(instance),
"CREATED": format_date(instance.created),
- "ERROR": error,
}
- add_row_from_dict(table, row)
-
- if len(fleet.instances) == 0 and fleet.status != FleetStatus.TERMINATING:
- row = {
- "FLEET": fleet.name,
- "RESERVATION": "-",
- "INSTANCE": "-",
- "BACKEND": "-",
- "REGION": "-",
- "RESOURCES": "-",
- "PRICE": "-",
- "STATUS": "-",
- "CREATED": format_date(fleet.created_at),
- "ERROR": "-",
- }
- add_row_from_dict(table, row)
+
+ if verbose:
+ instance_row["RESOURCES"] = _format_instance_resources(instance)
+ error = ""
+ if instance.status == InstanceStatus.TERMINATED and instance.termination_reason:
+ error = instance.termination_reason
+ instance_row["ERROR"] = error
+ else:
+ instance_row["GPU"] = _format_instance_gpu(instance)
+
+ add_row_from_dict(table, instance_row, style="secondary")
return table
+
+
+def _format_nodes(nodes: Optional[FleetNodesSpec]) -> str:
+ """Format nodes spec as '0..1', '3', '2..10', etc."""
+ if nodes is None:
+ return "-"
+ if nodes.min == nodes.max:
+ return str(nodes.min)
+ if nodes.max is None:
+ return f"{nodes.min}.."
+ return f"{nodes.min}..{nodes.max}"
+
+
+def _format_backends(backends: Optional[List[BackendType]]) -> str:
+ if backends is None or len(backends) == 0:
+ return "*"
+ return ", ".join(b.value.replace("remote", "ssh") for b in backends)
+
+
+def _format_range(min_val: Optional[Any], max_val: Optional[Any]) -> str:
+ if min_val is None and max_val is None:
+ return ""
+ if min_val == max_val:
+ return str(min_val)
+ if max_val is None:
+ return f"{min_val}.."
+ if min_val is None:
+ return f"..{max_val}"
+ return f"{min_val}..{max_val}"
+
+
+def _format_fleet_gpu(resources: Optional[ResourcesSpec]) -> str:
+ """Extract GPU-only info from fleet requirements, handling ranges."""
+ if resources is None or resources.gpu is None:
+ return "-"
+
+ gpu: GPUSpec = resources.gpu
+
+ # Check if there's actually a GPU requirement
+ count = gpu.count
+ if count is None or (count.min == 0 and (count.max is None or count.max == 0)):
+ return "-"
+
+ parts = []
+
+ # GPU name(s)
+ if gpu.name:
+ parts.append(",".join(gpu.name))
+ else:
+ parts.append("gpu")
+
+ # GPU memory (range)
+ if gpu.memory is not None:
+ mem_str = _format_range(gpu.memory.min, gpu.memory.max)
+ if mem_str:
+ parts.append(mem_str)
+
+ # GPU count (range)
+ count_str = _format_range(count.min, count.max)
+ if count_str:
+ parts.append(count_str)
+
+ return ":".join(parts)
+
+
+def _format_fleet_status(fleet: Fleet) -> str:
+ status = fleet.status
+ status_text = status.value
+
+ color_map = {
+ FleetStatus.SUBMITTED: "grey",
+ FleetStatus.ACTIVE: "white",
+ FleetStatus.TERMINATING: "deep_sky_blue1",
+ FleetStatus.TERMINATED: "grey",
+ FleetStatus.FAILED: "indian_red1",
+ }
+ color = color_map.get(status, "white")
+ is_finished = status in [FleetStatus.TERMINATED, FleetStatus.FAILED]
+ status_style = f"bold {color}" if not is_finished else color
+ return f"[{status_style}]{status_text}[/]"
+
+
+def _format_instance_status(instance: Instance) -> str:
+ """Format instance status with colors and health info."""
+ status = instance.status
+ status_text = status.value
+
+ total_blocks = instance.total_blocks
+ busy_blocks = instance.busy_blocks
+ if (
+ status in [InstanceStatus.IDLE, InstanceStatus.BUSY]
+ and total_blocks is not None
+ and total_blocks > 1
+ ):
+ status_text = f"{busy_blocks}/{total_blocks} {InstanceStatus.BUSY.value}"
+
+ # Add health status
+ health_suffix = ""
+ if status in [InstanceStatus.IDLE, InstanceStatus.BUSY]:
+ if instance.unreachable:
+ health_suffix = " (unreachable)"
+ elif not instance.health_status.is_healthy():
+ health_suffix = f" ({instance.health_status.value})"
+
+ color_map = {
+ InstanceStatus.PENDING: "deep_sky_blue1",
+ InstanceStatus.PROVISIONING: "deep_sky_blue1",
+ InstanceStatus.IDLE: "sea_green3",
+ InstanceStatus.BUSY: "white",
+ InstanceStatus.TERMINATING: "deep_sky_blue1",
+ InstanceStatus.TERMINATED: "grey",
+ }
+ color = color_map.get(status, "white")
+ is_finished = status == InstanceStatus.TERMINATED
+ status_style = f"bold {color}" if not is_finished else color
+ return f"[{status_style}]{status_text}{health_suffix}[/]"
+
+
+def _format_backend(backend: Optional[BackendType], region: Optional[str]) -> str:
+ if backend is None:
+ return "-"
+ backend_str = backend.value
+ if backend == BackendType.REMOTE:
+ backend_str = "ssh"
+ if region:
+ backend_str += f" ({region})"
+ return backend_str
+
+
+def _format_price(price: Optional[float]) -> str:
+ if price is None:
+ return "-"
+ return f"${price:.4f}".rstrip("0").rstrip(".")
+
+
+def _format_instance_gpu(instance: Instance) -> str:
+ if instance.instance_type is None:
+ return "-"
+ if instance.backend == BackendType.REMOTE and instance.status in [
+ InstanceStatus.PENDING,
+ InstanceStatus.PROVISIONING,
+ ]:
+ return "-"
+ return instance.instance_type.resources.pretty_format(gpu_only=True, include_spot=False) or "-"
+
+
+def _format_instance_resources(instance: Instance) -> str:
+ if instance.instance_type is None:
+ return "-"
+ if instance.backend == BackendType.REMOTE and instance.status in [
+ InstanceStatus.PENDING,
+ InstanceStatus.PROVISIONING,
+ ]:
+ return "-"
+ return instance.instance_type.resources.pretty_format(include_spot=False)
diff --git a/src/tests/_internal/cli/utils/test_fleet.py b/src/tests/_internal/cli/utils/test_fleet.py
new file mode 100644
index 0000000000..1c1df4df22
--- /dev/null
+++ b/src/tests/_internal/cli/utils/test_fleet.py
@@ -0,0 +1,502 @@
+import re
+from datetime import datetime, timezone
+from typing import List, Optional
+from uuid import uuid4
+
+from rich.table import Table
+from rich.text import Text
+
+from dstack._internal.cli.utils.fleet import get_fleets_table
+from dstack._internal.core.models.backends.base import BackendType
+from dstack._internal.core.models.fleets import (
+ Fleet,
+ FleetConfiguration,
+ FleetNodesSpec,
+ FleetSpec,
+ FleetStatus,
+ InstanceGroupPlacement,
+ SSHHostParams,
+ SSHParams,
+)
+from dstack._internal.core.models.instances import (
+ Disk,
+ Gpu,
+ Instance,
+ InstanceStatus,
+ InstanceType,
+ Resources,
+ SSHKey,
+)
+from dstack._internal.core.models.profiles import Profile, SpotPolicy
+from dstack._internal.core.models.resources import GPUSpec, Range, ResourcesSpec
+
+
+def _strip_rich_markup(text: str) -> str:
+ return re.sub(r"\[[^\]]*\]([^\[]*)\[/[^\]]*\]", r"\1", text)
+
+
+def get_table_cells(table: Table) -> list[dict[str, str]]:
+ rows = []
+
+ if not table.columns:
+ return rows
+
+ num_rows = len(table.columns[0]._cells)
+
+ for row_idx in range(num_rows):
+ row = {}
+ for col in table.columns:
+ col_name = str(col.header)
+ if row_idx < len(col._cells):
+ cell_value = col._cells[row_idx]
+ if isinstance(cell_value, Text):
+ row[col_name] = cell_value.plain
+ else:
+ text = str(cell_value)
+ row[col_name] = _strip_rich_markup(text)
+ else:
+ row[col_name] = ""
+ rows.append(row)
+
+ return rows
+
+
+def get_table_cell_style(table: Table, column_name: str, row_idx: int = 0) -> Optional[str]:
+ for col in table.columns:
+ if str(col.header) == column_name:
+ if row_idx < len(col._cells):
+ cell_value = col._cells[row_idx]
+ if isinstance(cell_value, Text):
+ return str(cell_value.style) if cell_value.style else None
+ text = str(cell_value)
+ match = re.search(r"\[([^\]]+)\][^\[]*\[/\]", text)
+ if match:
+ return match.group(1)
+ return None
+ return None
+
+
+def create_test_instance(
+ instance_num: int = 0,
+ backend: BackendType = BackendType.AWS,
+ region: str = "us-east-1",
+ status: InstanceStatus = InstanceStatus.IDLE,
+ price: Optional[float] = 0.50,
+ spot: bool = False,
+ gpu_name: Optional[str] = None,
+ gpu_count: int = 0,
+ gpu_memory_mib: int = 0,
+) -> Instance:
+ gpus = []
+ if gpu_count > 0 and gpu_name:
+ gpus = [Gpu(name=gpu_name, memory_mib=gpu_memory_mib)] * gpu_count
+
+ resources = Resources(
+ cpus=4,
+ memory_mib=16384,
+ gpus=gpus,
+ spot=spot,
+ disk=Disk(size_mib=102400),
+ )
+ instance_type = InstanceType(name="test-instance", resources=resources)
+
+ return Instance(
+ id=uuid4(),
+ project_name="test-project",
+ name=f"instance-{instance_num}",
+ instance_num=instance_num,
+ backend=backend,
+ region=region,
+ status=status,
+ price=price,
+ instance_type=instance_type,
+ created=datetime(2023, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
+ )
+
+
+def create_backend_fleet(
+ name: str = "test-fleet",
+ nodes_min: int = 0,
+ nodes_max: int = 2,
+ backends: Optional[List[BackendType]] = None,
+ spot_policy: SpotPolicy = SpotPolicy.AUTO,
+ max_price: Optional[float] = None,
+ placement: Optional[InstanceGroupPlacement] = None,
+ gpu_count_min: int = 0,
+ gpu_count_max: int = 0,
+ instances: Optional[List[Instance]] = None,
+ status: FleetStatus = FleetStatus.ACTIVE,
+) -> Fleet:
+ nodes = FleetNodesSpec(min=nodes_min, target=nodes_min, max=nodes_max)
+
+ gpu_spec = None
+ if gpu_count_max > 0:
+ gpu_spec = GPUSpec(count=Range[int](min=gpu_count_min, max=gpu_count_max))
+
+ resources = ResourcesSpec(gpu=gpu_spec) if gpu_spec else ResourcesSpec()
+
+ config = FleetConfiguration(
+ name=name,
+ nodes=nodes,
+ backends=backends,
+ placement=placement,
+ resources=resources,
+ )
+
+ profile = Profile(name="default", spot_policy=spot_policy, max_price=max_price)
+
+ spec = FleetSpec(
+ configuration=config,
+ configuration_path="fleet.dstack.yml",
+ profile=profile,
+ )
+
+ return Fleet(
+ id=uuid4(),
+ name=name,
+ project_name="test-project",
+ spec=spec,
+ created_at=datetime(2023, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
+ status=status,
+ instances=instances or [],
+ )
+
+
+def create_ssh_fleet(
+ name: str = "ssh-fleet",
+ hosts: Optional[List[str]] = None,
+ placement: Optional[InstanceGroupPlacement] = None,
+ instances: Optional[List[Instance]] = None,
+ status: FleetStatus = FleetStatus.ACTIVE,
+) -> Fleet:
+ if hosts is None:
+ hosts = ["10.0.0.1", "10.0.0.2"]
+
+ ssh_key = SSHKey(public="ssh-rsa AAAA...", private="-----BEGIN PRIVATE KEY-----\n...")
+ ssh_config = SSHParams(
+ user="ubuntu",
+ ssh_key=ssh_key,
+ hosts=[SSHHostParams(hostname=h) for h in hosts],
+ network=None,
+ )
+
+ config = FleetConfiguration(
+ name=name,
+ ssh_config=ssh_config,
+ placement=placement,
+ )
+
+ spec = FleetSpec(
+ configuration=config,
+ configuration_path="fleet.dstack.yml",
+ profile=Profile(name="default"),
+ )
+
+ return Fleet(
+ id=uuid4(),
+ name=name,
+ project_name="test-project",
+ spec=spec,
+ created_at=datetime(2023, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
+ status=status,
+ instances=instances or [],
+ )
+
+
+class TestGetFleetsTable:
+ def test_backend_fleet_without_verbose(self):
+ instance = create_test_instance(
+ instance_num=0,
+ backend=BackendType.AWS,
+ region="us-east-1",
+ status=InstanceStatus.IDLE,
+ price=0.50,
+ spot=True,
+ )
+ fleet = create_backend_fleet(
+ name="my-cloud",
+ nodes_min=0,
+ nodes_max=4,
+ backends=[BackendType.AWS],
+ spot_policy=SpotPolicy.AUTO,
+ instances=[instance],
+ )
+
+ table = get_fleets_table([fleet], verbose=False)
+ cells = get_table_cells(table)
+
+ assert len(cells) == 2 # 1 fleet row + 1 instance row
+
+ fleet_row = cells[0]
+ assert fleet_row["NAME"] == "my-cloud"
+ assert fleet_row["NODES"] == "0..4"
+ assert fleet_row["BACKEND"] == "aws"
+ assert fleet_row["SPOT"] == "auto"
+ assert fleet_row["PRICE"] == "-" # no max_price set
+ assert fleet_row["STATUS"] == "active"
+
+ instance_row = cells[1]
+ assert "instance=0" in instance_row["NAME"]
+ assert instance_row["BACKEND"] == "aws (us-east-1)"
+ assert instance_row["SPOT"] == "spot"
+ assert instance_row["PRICE"] == "$0.5"
+ assert instance_row["STATUS"] == "idle"
+
+ def test_backend_fleet_with_verbose(self):
+ instance = create_test_instance(
+ instance_num=0,
+ backend=BackendType.GCP,
+ region="us-west4",
+ status=InstanceStatus.BUSY,
+ price=1.25,
+ spot=False,
+ )
+ fleet = create_backend_fleet(
+ name="my-cloud",
+ nodes_min=1,
+ nodes_max=1,
+ backends=[BackendType.GCP],
+ spot_policy=SpotPolicy.ONDEMAND,
+ max_price=2.0,
+ placement=InstanceGroupPlacement.CLUSTER,
+ instances=[instance],
+ )
+
+ table = get_fleets_table([fleet], verbose=True)
+ cells = get_table_cells(table)
+
+ assert len(cells) == 2
+
+ fleet_row = cells[0]
+ assert fleet_row["NAME"] == "my-cloud"
+ assert fleet_row["NODES"] == "1 (cluster)"
+ assert fleet_row["BACKEND"] == "gcp"
+ assert fleet_row["SPOT"] == "on-demand"
+ assert fleet_row["PRICE"] == "$0..$2"
+ assert fleet_row["STATUS"] == "active"
+
+ instance_row = cells[1]
+ assert "instance=0" in instance_row["NAME"]
+ assert instance_row["BACKEND"] == "gcp (us-west4)"
+ assert instance_row["SPOT"] == "on-demand"
+ assert instance_row["PRICE"] == "$1.25"
+
+ def test_ssh_fleet_without_verbose(self):
+ instance1 = create_test_instance(
+ instance_num=0,
+ backend=BackendType.REMOTE,
+ region="",
+ status=InstanceStatus.IDLE,
+ price=None,
+ spot=False,
+ gpu_name="L4",
+ gpu_count=1,
+ gpu_memory_mib=24576,
+ )
+ instance2 = create_test_instance(
+ instance_num=1,
+ backend=BackendType.REMOTE,
+ region="",
+ status=InstanceStatus.BUSY,
+ price=None,
+ spot=False,
+ gpu_name="L4",
+ gpu_count=1,
+ gpu_memory_mib=24576,
+ )
+ fleet = create_ssh_fleet(
+ name="my-ssh",
+ hosts=["10.0.0.1", "10.0.0.2"],
+ instances=[instance1, instance2],
+ )
+
+ table = get_fleets_table([fleet], verbose=False)
+ cells = get_table_cells(table)
+
+ assert len(cells) == 3 # 1 fleet row + 2 instance rows
+
+ fleet_row = cells[0]
+ assert fleet_row["NAME"] == "my-ssh"
+ assert fleet_row["NODES"] == "2"
+ assert fleet_row["BACKEND"] == "ssh"
+ assert fleet_row["SPOT"] == "-"
+ assert fleet_row["PRICE"] == "-"
+ assert fleet_row["STATUS"] == "active"
+
+ for i, instance_row in enumerate(cells[1:], start=0):
+ assert f"instance={i}" in instance_row["NAME"]
+ assert instance_row["BACKEND"] == "ssh"
+ assert instance_row["SPOT"] == "-"
+ assert instance_row["PRICE"] == "-"
+
+ def test_ssh_fleet_with_verbose(self):
+ instance = create_test_instance(
+ instance_num=0,
+ backend=BackendType.REMOTE,
+ region="",
+ status=InstanceStatus.IDLE,
+ price=None,
+ spot=False,
+ )
+ fleet = create_ssh_fleet(
+ name="my-ssh",
+ hosts=["10.0.0.1"],
+ placement=InstanceGroupPlacement.CLUSTER,
+ instances=[instance],
+ )
+
+ table = get_fleets_table([fleet], verbose=True)
+ cells = get_table_cells(table)
+
+ assert len(cells) == 2
+
+ fleet_row = cells[0]
+ assert fleet_row["NAME"] == "my-ssh"
+ assert fleet_row["NODES"] == "1 (cluster)"
+ assert fleet_row["BACKEND"] == "ssh"
+ assert fleet_row["SPOT"] == "-"
+ assert fleet_row["PRICE"] == "-"
+
+ instance_row = cells[1]
+ assert "instance=0" in instance_row["NAME"]
+ assert instance_row["BACKEND"] == "ssh"
+ assert instance_row["SPOT"] == "-"
+ assert instance_row["PRICE"] == "-"
+
+ def test_mixed_fleets(self):
+ backend_instance = create_test_instance(
+ instance_num=0,
+ backend=BackendType.AWS,
+ region="us-east-1",
+ status=InstanceStatus.BUSY,
+ price=0.75,
+ spot=True,
+ )
+ backend_fleet = create_backend_fleet(
+ name="cloud-fleet",
+ nodes_min=0,
+ nodes_max=2,
+ backends=[BackendType.AWS],
+ spot_policy=SpotPolicy.SPOT,
+ instances=[backend_instance],
+ )
+
+ ssh_instance = create_test_instance(
+ instance_num=0,
+ backend=BackendType.REMOTE,
+ region="",
+ status=InstanceStatus.IDLE,
+ price=None,
+ spot=False,
+ )
+ ssh_fleet = create_ssh_fleet(
+ name="ssh-fleet",
+ hosts=["10.0.0.1"],
+ instances=[ssh_instance],
+ )
+
+ table = get_fleets_table([backend_fleet, ssh_fleet], verbose=False)
+ cells = get_table_cells(table)
+
+ assert len(cells) == 4 # 2 fleet rows + 2 instance rows
+
+ assert cells[0]["NAME"] == "cloud-fleet"
+ assert cells[0]["NODES"] == "0..2"
+ assert cells[0]["BACKEND"] == "aws"
+ assert cells[0]["SPOT"] == "spot"
+
+ assert "instance=0" in cells[1]["NAME"]
+ assert cells[1]["SPOT"] == "spot"
+ assert cells[1]["PRICE"] == "$0.75"
+
+ assert cells[2]["NAME"] == "ssh-fleet"
+ assert cells[2]["NODES"] == "1"
+ assert cells[2]["BACKEND"] == "ssh"
+ assert cells[2]["SPOT"] == "-"
+ assert cells[2]["PRICE"] == "-"
+
+ assert "instance=0" in cells[3]["NAME"]
+ assert cells[3]["SPOT"] == "-"
+ assert cells[3]["PRICE"] == "-"
+
+ def test_fleet_status_colors(self):
+ # Add instances to avoid placeholder rows affecting row indices
+ active_instance = create_test_instance(instance_num=0, status=InstanceStatus.IDLE)
+ active_fleet = create_backend_fleet(
+ name="active", status=FleetStatus.ACTIVE, instances=[active_instance]
+ )
+
+ terminating_instance = create_test_instance(
+ instance_num=0, status=InstanceStatus.TERMINATING
+ )
+ terminating_fleet = create_backend_fleet(
+ name="terminating", status=FleetStatus.TERMINATING, instances=[terminating_instance]
+ )
+
+ table = get_fleets_table([active_fleet, terminating_fleet], verbose=False)
+
+ active_style = get_table_cell_style(table, "STATUS", 0)
+ assert active_style == "bold white"
+
+ # Row 2 (after active fleet's instance)
+ terminating_style = get_table_cell_style(table, "STATUS", 2)
+ assert terminating_style == "bold deep_sky_blue1"
+
+ def test_instance_status_colors(self):
+ idle_instance = create_test_instance(instance_num=0, status=InstanceStatus.IDLE)
+ busy_instance = create_test_instance(instance_num=1, status=InstanceStatus.BUSY)
+
+ fleet = create_backend_fleet(
+ name="test",
+ instances=[idle_instance, busy_instance],
+ )
+
+ table = get_fleets_table([fleet], verbose=False)
+
+ idle_style = get_table_cell_style(table, "STATUS", 1)
+ assert idle_style == "bold sea_green3"
+
+ busy_style = get_table_cell_style(table, "STATUS", 2)
+ assert busy_style == "bold white"
+
+ def test_empty_fleet(self):
+ fleet = create_backend_fleet(name="empty-fleet", instances=[])
+
+ table = get_fleets_table([fleet], verbose=False)
+ cells = get_table_cells(table)
+
+ assert len(cells) == 1
+ assert cells[0]["NAME"] == "empty-fleet"
+
+ def test_fleet_with_max_price(self):
+ fleet = create_backend_fleet(
+ name="priced-fleet",
+ max_price=5.0,
+ )
+
+ table = get_fleets_table([fleet], verbose=False)
+ cells = get_table_cells(table)
+
+ assert cells[0]["PRICE"] == "$0..$5"
+
+ def test_fleet_with_multiple_backends(self):
+ fleet = create_backend_fleet(
+ name="multi-backend",
+ backends=[BackendType.AWS, BackendType.GCP, BackendType.AZURE],
+ )
+
+ table = get_fleets_table([fleet], verbose=False)
+ cells = get_table_cells(table)
+
+ assert cells[0]["BACKEND"] == "aws, gcp, azure"
+
+ def test_fleet_with_any_backend(self):
+ fleet = create_backend_fleet(
+ name="any-backend",
+ backends=None,
+ )
+
+ table = get_fleets_table([fleet], verbose=False)
+ cells = get_table_cells(table)
+
+ assert cells[0]["BACKEND"] == "*"
From 0182756b12588feb5364be88e42060c173fcbc1f Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Wed, 4 Feb 2026 16:20:42 +0100
Subject: [PATCH 060/640] [UX] Remove creation_policy from Concept #3527
(#3542)
---
docs/docs/concepts/snippets/manage-fleets.ext | 19 -------------------
1 file changed, 19 deletions(-)
diff --git a/docs/docs/concepts/snippets/manage-fleets.ext b/docs/docs/concepts/snippets/manage-fleets.ext
index 414aab91ab..a9d5c2d3e4 100644
--- a/docs/docs/concepts/snippets/manage-fleets.ext
+++ b/docs/docs/concepts/snippets/manage-fleets.ext
@@ -1,22 +1,3 @@
-### Creation policy
-
-By default, when you run `dstack apply` with a dev environment, task, or service,
-if no `idle` instances from the available fleets meet the requirements, `dstack` provisions a new instance
-using configured backends.
-
-To ensure `dstack apply` doesn't provision a new instance but reuses an existing one,
-pass `-R` (or `--reuse`) to `dstack apply`.
-
-
-
-```shell
-$ dstack apply -R -f examples/.dstack.yml
-```
-
-
-
-Or, set [`creation_policy`](../reference/dstack.yml/dev-environment.md#creation_policy) to `reuse` in the run configuration.
-
### Idle duration
If a run provisions a new instance, the instance stays `idle` for 5 minutes by default and can be reused within that time.
From 409da83c1f583497725e88468254f6d86f3d2071 Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Wed, 4 Feb 2026 19:20:54 +0100
Subject: [PATCH 061/640] [Bug]: Run doesn't show Waiting runner limit exceeded
in Error #3545 (#3546)
---
src/dstack/_internal/core/models/runs.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/dstack/_internal/core/models/runs.py b/src/dstack/_internal/core/models/runs.py
index 817dd4c497..88c2f38f5e 100644
--- a/src/dstack/_internal/core/models/runs.py
+++ b/src/dstack/_internal/core/models/runs.py
@@ -191,6 +191,7 @@ def to_error(self) -> Optional[str]:
error_mapping = {
JobTerminationReason.INSTANCE_UNREACHABLE: "instance unreachable",
JobTerminationReason.WAITING_INSTANCE_LIMIT_EXCEEDED: "waiting instance limit exceeded",
+ JobTerminationReason.WAITING_RUNNER_LIMIT_EXCEEDED: "waiting runner limit exceeded",
JobTerminationReason.VOLUME_ERROR: "volume error",
JobTerminationReason.GATEWAY_ERROR: "gateway error",
JobTerminationReason.SCALED_DOWN: "scaled down",
From 685e0cd87ed25fccbb26c13564d7815c34e1a92f Mon Sep 17 00:00:00 2001
From: Andrey Cheptsov <54148038+peterschmidt85@users.noreply.github.com>
Date: Wed, 4 Feb 2026 21:34:52 +0100
Subject: [PATCH 062/640] [Docs] Update SKILL.md (#3547)
* Corrected a typo in the configuration type help command.
* Enhanced descriptions for `dstack offer` command, including details on max offers and grouping options.
---
skills/dstack/SKILL.md | 27 +++++++++++++--------------
1 file changed, 13 insertions(+), 14 deletions(-)
diff --git a/skills/dstack/SKILL.md b/skills/dstack/SKILL.md
index 8ae5ce2696..1f32c089d0 100644
--- a/skills/dstack/SKILL.md
+++ b/skills/dstack/SKILL.md
@@ -58,7 +58,7 @@ description: |
- Never guess or invent flags. Example verification commands:
```bash
dstack --help # List all commands
- dstack apply --help # Flags for apply per configuration type (dev-environment, task, service, fleet, etc)
+ dstack apply --help # Flags for apply per configuration type (dev-environment, task, service, fleet, etc)
dstack fleet --help # Fleet subcommands
dstack ps --help # Flags for ps
```
@@ -412,12 +412,11 @@ dstack stop my-run-name -y
dstack stop my-run-name --abort
```
-### Check available resources
+### List offers
-```bash
-# List all available offers across backends
-dstack offer --json
+Offers represent available instance configurations available for provisioning across backends. `dstack offer` lists offers regardless of configured fleets.
+```bash
# Filter by specific backend
dstack offer --backend aws
@@ -429,9 +428,13 @@ dstack offer --gpu 24GB..80GB
# Combine filters
dstack offer --backend aws --gpu A100:80GB
+
+# JSON output (for troubleshooting/scripting)
+dstack offer --json
```
-**Note:** `dstack offer` lists offers across backends regardless of configured fleets.
+**Max offers:** By default, `dstack offer` returns first N offers (output also includes the total number). Use `--max-offers N` to increase the limit.
+**Grouping:** Prefer `--group-by gpu` (other supported values: `gpu,backend`, `gpu,backend,region`) for aggregated output across all offers, not `--max-offers`.
## Troubleshooting
@@ -439,9 +442,10 @@ When diagnosing issues with dstack workloads or infrastructure:
1. **Use JSON output for detailed inspection:**
```bash
- dstack fleet get my-fleet --json | jq .
- dstack run get my-run --json | jq .
- dstack ps -n 10 --json | jq .
+ dstack fleet get my-fleet --json
+ dstack run get my-run --json
+ dstack ps -n 10 --json
+ dstack offer --json
```
2. **Check verbose run status:**
@@ -459,11 +463,6 @@ When diagnosing issues with dstack workloads or infrastructure:
dstack attach my-run --logs
```
-5. **Verify resource availability:**
- ```bash
- dstack offer --backend aws --gpu A100 --spot-auto --json
- ```
-
Common issues:
- **No offers:** Check `dstack offer` and ensure that at least one fleet matches requirements
- **No fleet:** Ensure at least one fleet is created
From 78678c14b0dcef8100dccf523afe0f5f87922578 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Thu, 5 Feb 2026 07:39:13 +0000
Subject: [PATCH 063/640] Fix `probes=None` client incompatibility (#3544)
This fixes client compatibility with servers prior
to 0.20.8 that don't support `probes=None`.
---
src/dstack/_internal/core/compatibility/runs.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/dstack/_internal/core/compatibility/runs.py b/src/dstack/_internal/core/compatibility/runs.py
index 90ef9d98a8..19c08cde55 100644
--- a/src/dstack/_internal/core/compatibility/runs.py
+++ b/src/dstack/_internal/core/compatibility/runs.py
@@ -68,6 +68,9 @@ def get_run_spec_excludes(run_spec: RunSpec) -> IncludeExcludeDictType:
configuration_excludes["probes"] = {"__all__": probe_excludes}
if all(p.until_ready is None for p in run_spec.configuration.probes):
probe_excludes["until_ready"] = True
+ elif run_spec.configuration.probes is None:
+ # Servers prior to 0.20.8 do not support probes=None
+ configuration_excludes["probes"] = True
if configuration_excludes:
spec_excludes["configuration"] = configuration_excludes
From 3149be8ff126ad99b6dca56c5d4d800768f1e6d7 Mon Sep 17 00:00:00 2001
From: jvstme <36324149+jvstme@users.noreply.github.com>
Date: Thu, 5 Feb 2026 07:39:37 +0000
Subject: [PATCH 064/640] Fix `probes=None` server incompatibility (#3543)
This fixes server compatibility with clients prior
to 0.20.8 that don't support `probes=None`, by
replacing `None` with `[]` in responses for older
clients. This incompatibility could be observed
when there are both new and old clients in the
same project, so old clients would fail when
viewing runs submitted by new clients.
---
.../_internal/server/compatibility/runs.py | 36 ++++
src/dstack/_internal/server/routers/runs.py | 58 ++---
.../_internal/server/routers/test_runs.py | 204 ++++++++++++++++++
3 files changed, 271 insertions(+), 27 deletions(-)
create mode 100644 src/dstack/_internal/server/compatibility/runs.py
diff --git a/src/dstack/_internal/server/compatibility/runs.py b/src/dstack/_internal/server/compatibility/runs.py
new file mode 100644
index 0000000000..2e715ce162
--- /dev/null
+++ b/src/dstack/_internal/server/compatibility/runs.py
@@ -0,0 +1,36 @@
+from typing import Optional
+
+from packaging.version import Version
+
+from dstack._internal.core.models.configurations import ServiceConfiguration
+from dstack._internal.core.models.runs import Run, RunPlan, RunSpec
+from dstack._internal.server.compatibility.common import patch_offers_list
+
+
+def patch_run_plan(run_plan: RunPlan, client_version: Optional[Version]) -> None:
+ if client_version is None:
+ return
+ patch_run_spec(run_plan.run_spec, client_version)
+ if run_plan.effective_run_spec is not None:
+ patch_run_spec(run_plan.effective_run_spec, client_version)
+ if run_plan.current_resource is not None:
+ patch_run(run_plan.current_resource, client_version)
+ for job_plan in run_plan.job_plans:
+ patch_offers_list(job_plan.offers, client_version)
+
+
+def patch_run(run: Run, client_version: Optional[Version]) -> None:
+ if client_version is None:
+ return
+ patch_run_spec(run.run_spec, client_version)
+
+
+def patch_run_spec(run_spec: RunSpec, client_version: Optional[Version]) -> None:
+ if client_version is None:
+ return
+ # Clients prior to 0.20.8 do not support probes = None
+ if client_version < Version("0.20.8") and isinstance(
+ run_spec.configuration, ServiceConfiguration
+ ):
+ if run_spec.configuration.probes is None:
+ run_spec.configuration.probes = []
diff --git a/src/dstack/_internal/server/routers/runs.py b/src/dstack/_internal/server/routers/runs.py
index 27d378d8ba..d9670a7245 100644
--- a/src/dstack/_internal/server/routers/runs.py
+++ b/src/dstack/_internal/server/routers/runs.py
@@ -6,7 +6,7 @@
from dstack._internal.core.errors import ResourceNotExistsError
from dstack._internal.core.models.runs import Run, RunPlan
-from dstack._internal.server.compatibility.common import patch_offers_list
+from dstack._internal.server.compatibility.runs import patch_run, patch_run_plan
from dstack._internal.server.db import get_session
from dstack._internal.server.models import ProjectModel, UserModel
from dstack._internal.server.schemas.runs import (
@@ -52,6 +52,7 @@ async def list_runs(
body: ListRunsRequest,
session: AsyncSession = Depends(get_session),
user: UserModel = Depends(Authenticated()),
+ client_version: Optional[Version] = Depends(get_client_version),
):
"""
Returns all runs visible to user sorted by descending `submitted_at`.
@@ -62,22 +63,23 @@ async def list_runs(
The results are paginated. To get the next page, pass `submitted_at` and `id` of
the last run from the previous page as `prev_submitted_at` and `prev_run_id`.
"""
- return CustomORJSONResponse(
- await runs.list_user_runs(
- session=session,
- user=user,
- project_name=body.project_name,
- repo_id=body.repo_id,
- username=body.username,
- only_active=body.only_active,
- include_jobs=body.include_jobs,
- job_submissions_limit=body.job_submissions_limit,
- prev_submitted_at=body.prev_submitted_at,
- prev_run_id=body.prev_run_id,
- limit=body.limit,
- ascending=body.ascending,
- )
+ run_list = await runs.list_user_runs(
+ session=session,
+ user=user,
+ project_name=body.project_name,
+ repo_id=body.repo_id,
+ username=body.username,
+ only_active=body.only_active,
+ include_jobs=body.include_jobs,
+ job_submissions_limit=body.job_submissions_limit,
+ prev_submitted_at=body.prev_submitted_at,
+ prev_run_id=body.prev_run_id,
+ limit=body.limit,
+ ascending=body.ascending,
)
+ for run in run_list:
+ patch_run(run, client_version)
+ return CustomORJSONResponse(run_list)
@project_router.post(
@@ -88,6 +90,7 @@ async def get_run(
body: GetRunRequest,
session: AsyncSession = Depends(get_session),
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
+ client_version: Optional[Version] = Depends(get_client_version),
):
"""
Returns a run given `run_name` or `id`.
@@ -103,6 +106,7 @@ async def get_run(
)
if run is None:
raise ResourceNotExistsError("Run not found")
+ patch_run(run, client_version)
return CustomORJSONResponse(run)
@@ -132,8 +136,7 @@ async def get_plan(
max_offers=body.max_offers,
legacy_repo_dir=legacy_repo_dir,
)
- for job_plan in run_plan.job_plans:
- patch_offers_list(job_plan.offers, client_version)
+ patch_run_plan(run_plan, client_version)
return CustomORJSONResponse(run_plan)
@@ -146,6 +149,7 @@ async def apply_plan(
session: Annotated[AsyncSession, Depends(get_session)],
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectMember())],
legacy_repo_dir: Annotated[bool, Depends(use_legacy_repo_dir)],
+ client_version: Annotated[Optional[Version], Depends(get_client_version)],
):
"""
Creates a new run or updates an existing run.
@@ -156,16 +160,16 @@ async def apply_plan(
user, project = user_project
if not user.ssh_public_key and not body.plan.run_spec.ssh_key_pub:
await users.refresh_ssh_key(session=session, actor=user)
- return CustomORJSONResponse(
- await runs.apply_plan(
- session=session,
- user=user,
- project=project,
- plan=body.plan,
- force=body.force,
- legacy_repo_dir=legacy_repo_dir,
- )
+ run = await runs.apply_plan(
+ session=session,
+ user=user,
+ project=project,
+ plan=body.plan,
+ force=body.force,
+ legacy_repo_dir=legacy_repo_dir,
)
+ patch_run(run, client_version)
+ return CustomORJSONResponse(run)
@project_router.post("/stop")
diff --git a/src/tests/_internal/server/routers/test_runs.py b/src/tests/_internal/server/routers/test_runs.py
index 35a251b2cf..ad8ad878d1 100644
--- a/src/tests/_internal/server/routers/test_runs.py
+++ b/src/tests/_internal/server/routers/test_runs.py
@@ -941,6 +941,53 @@ async def test_limits_job_submissions(
},
]
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "client_version,expected_probes",
+ [
+ ("0.20.7", []),
+ ("0.20.8", None),
+ (None, None),
+ ],
+ )
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_patches_service_configuration_probes_for_old_clients(
+ self,
+ test_db,
+ session: AsyncSession,
+ client: AsyncClient,
+ client_version: Optional[str],
+ expected_probes: Optional[list],
+ ) -> None:
+ user = await create_user(session=session)
+ project = await create_project(session=session, owner=user)
+ repo = await create_repo(session=session, project_id=project.id)
+
+ service_conf = ServiceConfiguration(
+ commands=["echo hello"],
+ port=80,
+ probes=None, # This should be patched to [] for clients prior to 0.20.8
+ )
+ run_spec = get_run_spec(
+ configuration=service_conf,
+ repo_id=repo.name,
+ )
+ await create_run(session=session, project=project, repo=repo, user=user, run_spec=run_spec)
+
+ headers = get_auth_headers(user.token)
+ if client_version is not None:
+ headers["X-API-Version"] = client_version
+ response = await client.post(
+ "/api/runs/list",
+ headers=headers,
+ json={"project_name": project.name},
+ )
+
+ assert response.status_code == 200
+ runs_list = response.json()
+ assert len(runs_list) == 1
+ assert runs_list[0]["run_spec"]["configuration"]["probes"] == expected_probes
+
class TestGetRun:
@pytest.mark.asyncio
@@ -1020,6 +1067,53 @@ async def test_returns_deleted_run_given_id(
assert response.status_code == 200, response.json()
assert response.json()["id"] == str(run.id)
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "client_version,expected_probes",
+ [
+ ("0.20.7", []),
+ ("0.20.8", None),
+ (None, None),
+ ],
+ )
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_patches_service_configuration_probes_for_old_clients(
+ self,
+ test_db,
+ session: AsyncSession,
+ client: AsyncClient,
+ client_version: Optional[str],
+ expected_probes: Optional[list],
+ ) -> None:
+ user = await create_user(session=session)
+ project = await create_project(session=session, owner=user)
+ repo = await create_repo(session=session, project_id=project.id)
+
+ service_conf = ServiceConfiguration(
+ commands=["echo hello"],
+ port=80,
+ probes=None, # This should be patched to [] for clients prior to 0.20.8
+ )
+ run_spec = get_run_spec(
+ configuration=service_conf,
+ repo_id=repo.name,
+ )
+ run = await create_run(
+ session=session, project=project, repo=repo, user=user, run_spec=run_spec
+ )
+
+ headers = get_auth_headers(user.token)
+ if client_version is not None:
+ headers["X-API-Version"] = client_version
+ response = await client.post(
+ f"/api/project/{project.name}/runs/get",
+ headers=headers,
+ json={"run_name": run.run_name},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["run_spec"]["configuration"]["probes"] == expected_probes
+
class TestGetRunPlan:
@pytest.mark.asyncio
@@ -1477,6 +1571,65 @@ async def test_generates_user_ssh_key(self, session: AsyncSession, client: Async
assert user.ssh_public_key == run_spec_ssh_public_key
assert user.ssh_private_key is not None
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "client_version,expected_probes",
+ [
+ ("0.20.7", []),
+ ("0.20.8", None),
+ (None, None),
+ ],
+ )
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_patches_service_configuration_probes_for_old_clients(
+ self,
+ test_db,
+ session: AsyncSession,
+ client: AsyncClient,
+ client_version: Optional[str],
+ expected_probes: Optional[list],
+ ) -> None:
+ user = await create_user(session=session)
+ project = await create_project(session=session, owner=user)
+ repo = await create_repo(session=session, project_id=project.id)
+
+ service_conf = ServiceConfiguration(
+ commands=["echo hello"],
+ port=80,
+ probes=None, # This should be patched to [] for clients prior to 0.20.8
+ )
+ run_spec = get_run_spec(
+ run_name="test-service",
+ configuration=service_conf,
+ repo_id=repo.name,
+ )
+ await create_run(
+ session=session,
+ project=project,
+ repo=repo,
+ user=user,
+ run_spec=run_spec,
+ run_name="test-service",
+ )
+
+ body = {"run_spec": run_spec.dict()}
+ headers = get_auth_headers(user.token)
+ if client_version is not None:
+ headers["X-API-Version"] = client_version
+ response = await client.post(
+ f"/api/project/{project.name}/runs/get_plan",
+ headers=headers,
+ json=body,
+ )
+
+ assert response.status_code == 200
+ run_plan = response.json()
+ assert run_plan["run_spec"]["configuration"]["probes"] == expected_probes
+ assert run_plan["effective_run_spec"]["configuration"]["probes"] == expected_probes
+ assert (
+ run_plan["current_resource"]["run_spec"]["configuration"]["probes"] == expected_probes
+ )
+
class TestApplyPlan:
@pytest.mark.asyncio
@@ -1668,6 +1821,57 @@ async def test_generates_user_ssh_key(self, session: AsyncSession, client: Async
assert user.ssh_public_key == run_spec_ssh_public_key
assert user.ssh_private_key is not None
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "client_version,expected_probes",
+ [
+ ("0.20.7", []), # Prior to 0.20.8, probes=None should be patched to []
+ ("0.20.8", None), # 0.20.8 and later should keep probes=None
+ (None, None), # None client version should keep probes=None
+ ],
+ )
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
+ async def test_patches_service_configuration_probes_for_old_clients(
+ self,
+ test_db,
+ session: AsyncSession,
+ client: AsyncClient,
+ client_version: Optional[str],
+ expected_probes: Optional[list],
+ ) -> None:
+ user = await create_user(session=session)
+ project = await create_project(session=session, owner=user)
+ repo = await create_repo(session=session, project_id=project.id)
+
+ service_conf = ServiceConfiguration(
+ commands=["echo hello"],
+ port=80,
+ probes=None, # This should be patched to [] for clients prior to 0.20.8
+ )
+ run_spec = get_run_spec(
+ run_name="test-service",
+ configuration=service_conf,
+ repo_id=repo.name,
+ )
+
+ headers = get_auth_headers(user.token)
+ if client_version is not None:
+ headers["X-API-Version"] = client_version
+ response = await client.post(
+ f"/api/project/{project.name}/runs/apply",
+ headers=headers,
+ json={
+ "plan": {
+ "run_spec": run_spec.dict(),
+ "current_resource": None,
+ },
+ "force": False,
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.json()["run_spec"]["configuration"]["probes"] == expected_probes
+
class TestSubmitRun:
@pytest.mark.asyncio
From 3ec1a6de7a479f2868eab592f6f1f1025333c68c Mon Sep 17 00:00:00 2001
From: Oleg
Date: Thu, 5 Feb 2026 12:29:51 +0300
Subject: [PATCH 065/640] Events UI #3309 (#3532)
* Events UI #3309 Added links to event list from project and user details pages
* Events UI #3309 Added events and activity tabs on user details page
* Events UI #3309 User events refactoring after review
* Events UI #3309 User events refactoring after review
---
frontend/src/components/index.ts | 2 +
frontend/src/locale/en.json | 2 +
frontend/src/pages/Events/List/ListPage.tsx | 46 +++
.../src/pages/Events/List/hooks/useFilters.ts | 324 +++++++++++-------
frontend/src/pages/Events/List/index.tsx | 95 ++---
frontend/src/pages/Events/index.tsx | 2 +-
.../pages/Project/Details/Events/index.tsx | 55 +++
frontend/src/pages/Project/Details/index.tsx | 46 ++-
.../pages/Project/Details/styles.module.scss | 18 +
frontend/src/pages/Project/index.tsx | 1 +
.../src/pages/User/Details/Events/index.tsx | 64 ++++
.../src/pages/User/Details/Projects/index.tsx | 12 +-
.../src/pages/User/Details/Settings/index.tsx | 30 +-
frontend/src/pages/User/Details/index.tsx | 23 +-
.../src/pages/User/Details/styles.module.scss | 18 +
frontend/src/pages/User/Details/types.ts | 2 +
frontend/src/router.tsx | 12 +-
frontend/src/routes.ts | 9 +
frontend/src/types/event.d.ts | 3 +-
19 files changed, 554 insertions(+), 210 deletions(-)
create mode 100644 frontend/src/pages/Events/List/ListPage.tsx
create mode 100644 frontend/src/pages/Project/Details/Events/index.tsx
create mode 100644 frontend/src/pages/Project/Details/styles.module.scss
create mode 100644 frontend/src/pages/User/Details/Events/index.tsx
create mode 100644 frontend/src/pages/User/Details/styles.module.scss
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index 3e5d353c96..6acd2a2b05 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -66,6 +66,8 @@ export { default as ExpandableSection } from '@cloudscape-design/components/expa
export { default as KeyValuePairs } from '@cloudscape-design/components/key-value-pairs';
export { I18nProvider } from '@cloudscape-design/components/i18n';
export { default as Wizard } from '@cloudscape-design/components/wizard';
+export { default as SegmentedControl } from '@cloudscape-design/components/segmented-control';
+export type { SegmentedControlProps } from '@cloudscape-design/components/segmented-control';
// custom components
export { NavigateLink } from './NavigateLink';
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index db074ba2cd..56c5e2efce 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -182,6 +182,7 @@
"repositories": "Repositories",
"runs": "Runs",
"tags": "Tags",
+ "events": "Events",
"settings": "Settings",
"join": "Join",
"leave_confirm_title": "Leave project",
@@ -697,6 +698,7 @@
"account_settings": "User settings",
"settings": "Settings",
"projects": "Projects",
+ "events": "Events",
"create": {
"page_title": "Create user",
"error_notification": "Create user error",
diff --git a/frontend/src/pages/Events/List/ListPage.tsx b/frontend/src/pages/Events/List/ListPage.tsx
new file mode 100644
index 0000000000..9117b37bf0
--- /dev/null
+++ b/frontend/src/pages/Events/List/ListPage.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Button, Header, SpaceBetween } from 'components';
+
+import { useBreadcrumbs } from 'hooks';
+import { ROUTES } from 'routes';
+
+import { EventList } from './index';
+
+export const ListPage: React.FC = () => {
+ const { t } = useTranslation();
+
+ useBreadcrumbs([
+ {
+ text: t('navigation.events'),
+ href: ROUTES.EVENTS.LIST,
+ },
+ ]);
+
+ return (
+ {
+ return (
+
+
+
+ }
+ >
+ {t('navigation.events')}
+
+ );
+ }}
+ />
+ );
+};
diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts
index 6a82b3a654..6413c40392 100644
--- a/frontend/src/pages/Events/List/hooks/useFilters.ts
+++ b/frontend/src/pages/Events/List/hooks/useFilters.ts
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
+import { omit } from 'lodash';
import type { PropertyFilterProps } from 'components';
@@ -9,23 +10,7 @@ import { useGetUserListQuery } from 'services/user';
import { filterLastElementByPrefix } from '../helpers';
-type RequestParamsKeys = keyof Pick<
- TEventListRequestParams,
- | 'target_projects'
- | 'target_users'
- | 'target_fleets'
- | 'target_instances'
- | 'target_runs'
- | 'target_jobs'
- | 'target_volumes'
- | 'target_gateways'
- | 'target_secrets'
- | 'within_projects'
- | 'within_fleets'
- | 'within_runs'
- | 'include_target_types'
- | 'actors'
->;
+type RequestParamsKeys = keyof TEventListFilters;
const filterKeys: Record = {
TARGET_PROJECTS: 'target_projects',
@@ -75,17 +60,107 @@ const targetTypes = [
{ label: 'Secret', value: 'secret' },
];
-export const useFilters = () => {
+const baseFilteringProperties = [
+ {
+ key: filterKeys.TARGET_PROJECTS,
+ operators: ['='],
+ propertyLabel: 'Target projects',
+ groupValuesLabel: 'Project ids',
+ },
+ {
+ key: filterKeys.TARGET_USERS,
+ operators: ['='],
+ propertyLabel: 'Target users',
+ groupValuesLabel: 'Project ids',
+ },
+ {
+ key: filterKeys.TARGET_FLEETS,
+ operators: ['='],
+ propertyLabel: 'Target fleet IDs',
+ },
+ {
+ key: filterKeys.TARGET_INSTANCES,
+ operators: ['='],
+ propertyLabel: 'Target instance IDs',
+ },
+ {
+ key: filterKeys.TARGET_RUNS,
+ operators: ['='],
+ propertyLabel: 'Target run IDs',
+ },
+ {
+ key: filterKeys.TARGET_JOBS,
+ operators: ['='],
+ propertyLabel: 'Target job IDs',
+ },
+ {
+ key: filterKeys.TARGET_VOLUMES,
+ operators: ['='],
+ propertyLabel: 'Target volume IDs',
+ },
+ {
+ key: filterKeys.TARGET_GATEWAYS,
+ operators: ['='],
+ propertyLabel: 'Target gateway IDs',
+ },
+ {
+ key: filterKeys.TARGET_SECRETS,
+ operators: ['='],
+ propertyLabel: 'Target secret IDs',
+ },
+
+ {
+ key: filterKeys.WITHIN_PROJECTS,
+ operators: ['='],
+ propertyLabel: 'Within projects',
+ groupValuesLabel: 'Project ids',
+ },
+
+ {
+ key: filterKeys.WITHIN_FLEETS,
+ operators: ['='],
+ propertyLabel: 'Within fleet IDs',
+ },
+
+ {
+ key: filterKeys.WITHIN_RUNS,
+ operators: ['='],
+ propertyLabel: 'Within run IDs',
+ },
+
+ {
+ key: filterKeys.INCLUDE_TARGET_TYPES,
+ operators: ['='],
+ propertyLabel: 'Target types',
+ groupValuesLabel: 'Target type values',
+ },
+
+ {
+ key: filterKeys.ACTORS,
+ operators: ['='],
+ propertyLabel: 'Actors',
+ },
+];
+
+export const useFilters = ({
+ permanentFilters,
+ withSearchParams,
+}: {
+ permanentFilters?: Partial;
+ withSearchParams?: boolean;
+}) => {
const [searchParams, setSearchParams] = useSearchParams();
- const { data: projectsData } = useGetProjectsQuery({});
- const { data: usersData } = useGetUserListQuery({});
+ const { data: projectsData, isLoading: isLoadingProjects } = useGetProjectsQuery({});
+ const { data: usersData, isLoading: isLoadingUsers } = useGetUserListQuery({});
const [propertyFilterQuery, setPropertyFilterQuery] = useState(() =>
requestParamsToTokens({ searchParams, filterKeys }),
);
const clearFilter = () => {
- setSearchParams({});
+ if (withSearchParams) {
+ setSearchParams({});
+ }
setPropertyFilterQuery(EMPTY_QUERY);
};
@@ -132,88 +207,6 @@ export const useFilters = () => {
setSearchParams(searchParams);
};
- const filteringProperties = [
- {
- key: filterKeys.TARGET_PROJECTS,
- operators: ['='],
- propertyLabel: 'Target projects',
- groupValuesLabel: 'Project ids',
- },
- {
- key: filterKeys.TARGET_USERS,
- operators: ['='],
- propertyLabel: 'Target users',
- groupValuesLabel: 'Project ids',
- },
- {
- key: filterKeys.TARGET_FLEETS,
- operators: ['='],
- propertyLabel: 'Target fleet IDs',
- },
- {
- key: filterKeys.TARGET_INSTANCES,
- operators: ['='],
- propertyLabel: 'Target instance IDs',
- },
- {
- key: filterKeys.TARGET_RUNS,
- operators: ['='],
- propertyLabel: 'Target run IDs',
- },
- {
- key: filterKeys.TARGET_JOBS,
- operators: ['='],
- propertyLabel: 'Target job IDs',
- },
- {
- key: filterKeys.TARGET_VOLUMES,
- operators: ['='],
- propertyLabel: 'Target volume IDs',
- },
- {
- key: filterKeys.TARGET_GATEWAYS,
- operators: ['='],
- propertyLabel: 'Target gateway IDs',
- },
- {
- key: filterKeys.TARGET_SECRETS,
- operators: ['='],
- propertyLabel: 'Target secret IDs',
- },
-
- {
- key: filterKeys.WITHIN_PROJECTS,
- operators: ['='],
- propertyLabel: 'Within projects',
- groupValuesLabel: 'Project ids',
- },
-
- {
- key: filterKeys.WITHIN_FLEETS,
- operators: ['='],
- propertyLabel: 'Within fleet IDs',
- },
-
- {
- key: filterKeys.WITHIN_RUNS,
- operators: ['='],
- propertyLabel: 'Within run IDs',
- },
-
- {
- key: filterKeys.INCLUDE_TARGET_TYPES,
- operators: ['='],
- propertyLabel: 'Target types',
- groupValuesLabel: 'Target type values',
- },
-
- {
- key: filterKeys.ACTORS,
- operators: ['='],
- propertyLabel: 'Actors',
- },
- ];
-
const onChangePropertyFilterHandle = ({ tokens, operation }: PropertyFilterProps.Query) => {
let filteredTokens = [...tokens];
@@ -225,7 +218,9 @@ export const useFilters = () => {
}
});
- setSearchParamsHandle({ tokens: filteredTokens });
+ if (withSearchParams) {
+ setSearchParamsHandle({ tokens: filteredTokens });
+ }
setPropertyFilterQuery({
operation,
@@ -237,60 +232,130 @@ export const useFilters = () => {
onChangePropertyFilterHandle(detail);
};
+ const filteringProperties = useMemo(() => {
+ const permanentFiltersKeysMap = new Map();
+
+ for (const prefix of onlyOneFilterGroupPrefixes) {
+ const permanentFilterKey = Object.keys(permanentFilters ?? {}).find((filterKey) => filterKey.startsWith(prefix));
+
+ if (permanentFilterKey) {
+ permanentFiltersKeysMap.set(prefix, permanentFilterKey);
+ }
+ }
+
+ if (permanentFiltersKeysMap.size === 0) {
+ return baseFilteringProperties;
+ }
+
+ return baseFilteringProperties.filter(({ key }) => {
+ const propertyPrefix = onlyOneFilterGroupPrefixes.find((prefix) => key.startsWith(prefix));
+
+ if (!propertyPrefix) {
+ return true;
+ }
+
+ if (permanentFiltersKeysMap.has(propertyPrefix)) {
+ return key === permanentFiltersKeysMap.get(propertyPrefix);
+ }
+
+ return true;
+ });
+ }, [permanentFilters]);
+
const filteringRequestParams = useMemo(() => {
const params = tokensToRequestParams({
tokens: propertyFilterQuery.tokens,
arrayFieldKeys: multipleChoiseKeys,
});
+ const filterParamsWithPermanentFitters = (filterKey: RequestParamsKeys): string[] => {
+ let paramsFilter = params[filterKey] ?? '';
+ const permanentFilter = permanentFilters?.[filterKey] ?? '';
+
+ if (!Array.isArray(paramsFilter) && typeof paramsFilter === 'object') {
+ paramsFilter = '';
+ }
+
+ if (Array.isArray(paramsFilter) && Array.isArray(permanentFilter)) {
+ return [...paramsFilter, ...permanentFilter];
+ }
+
+ if (Array.isArray(paramsFilter) && !Array.isArray(permanentFilter)) {
+ return [...paramsFilter, permanentFilter];
+ }
+
+ if (!Array.isArray(paramsFilter) && Array.isArray(permanentFilter)) {
+ return [paramsFilter, ...permanentFilter];
+ }
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ return [paramsFilter, permanentFilter];
+ };
+
+ const targetProjects = filterParamsWithPermanentFitters(filterKeys.TARGET_PROJECTS)
+ .map((name: string) => projectsData?.data?.find(({ project_name }) => project_name === name)?.['project_id'])
+ .filter(Boolean);
+
+ const withInProjects = filterParamsWithPermanentFitters(filterKeys.WITHIN_PROJECTS)
+ .map((name: string) => projectsData?.data?.find(({ project_name }) => project_name === name)?.['project_id'])
+ .filter(Boolean);
+
+ const targetUsers = filterParamsWithPermanentFitters(filterKeys.TARGET_USERS)
+ .map((name: string) => usersData?.data?.find(({ username }) => username === name)?.['id'])
+ .filter(Boolean);
+
+ const actors = filterParamsWithPermanentFitters(filterKeys.ACTORS)
+ .map((name: string) => usersData?.data?.find(({ username }) => username === name)?.['id'])
+ .filter(Boolean);
+
+ const includeTargetTypes = filterParamsWithPermanentFitters(filterKeys.INCLUDE_TARGET_TYPES)
+ .map((selectedLabel: string) => targetTypes?.find(({ label }) => label === selectedLabel)?.['value'])
+ .filter(Boolean);
+
const mappedFields = {
- ...(params[filterKeys.TARGET_PROJECTS] && Array.isArray(params[filterKeys.TARGET_PROJECTS])
+ ...(targetProjects?.length
? {
- [filterKeys.TARGET_PROJECTS]: params[filterKeys.TARGET_PROJECTS]?.map(
- (name: string) =>
- projectsData?.data?.find(({ project_name }) => project_name === name)?.['project_id'],
- ),
+ [filterKeys.TARGET_PROJECTS]: targetProjects,
}
: {}),
- ...(params[filterKeys.WITHIN_PROJECTS] && Array.isArray(params[filterKeys.WITHIN_PROJECTS])
+ ...(withInProjects?.length
? {
- [filterKeys.WITHIN_PROJECTS]: params[filterKeys.WITHIN_PROJECTS]?.map(
- (name: string) =>
- projectsData?.data?.find(({ project_name }) => project_name === name)?.['project_id'],
- ),
+ [filterKeys.WITHIN_PROJECTS]: withInProjects,
}
: {}),
- ...(params[filterKeys.TARGET_USERS] && Array.isArray(params[filterKeys.TARGET_USERS])
+ ...(targetUsers?.length
? {
- [filterKeys.TARGET_USERS]: params[filterKeys.TARGET_USERS]?.map(
- (name: string) => usersData?.data?.find(({ username }) => username === name)?.['id'],
- ),
+ [filterKeys.TARGET_USERS]: targetUsers,
}
: {}),
- ...(params[filterKeys.ACTORS] && Array.isArray(params[filterKeys.ACTORS])
+ ...(actors?.length
? {
- [filterKeys.ACTORS]: params[filterKeys.ACTORS]?.map(
- (name: string) => usersData?.data?.find(({ username }) => username === name)?.['id'],
- ),
+ [filterKeys.ACTORS]: actors,
}
: {}),
- ...(params[filterKeys.INCLUDE_TARGET_TYPES] && Array.isArray(params[filterKeys.INCLUDE_TARGET_TYPES])
+ ...(includeTargetTypes?.length
? {
- [filterKeys.INCLUDE_TARGET_TYPES]: params[filterKeys.INCLUDE_TARGET_TYPES]?.map(
- (selectedLabel: string) => targetTypes?.find(({ label }) => label === selectedLabel)?.['value'],
- ),
+ [filterKeys.INCLUDE_TARGET_TYPES]: includeTargetTypes,
}
: {}),
};
return {
- ...params,
+ ...omit(params, [
+ filterKeys.TARGET_PROJECTS,
+ filterKeys.WITHIN_PROJECTS,
+ filterKeys.TARGET_USERS,
+ filterKeys.ACTORS,
+ filterKeys.INCLUDE_TARGET_TYPES,
+ ]),
+ ...permanentFilters,
...mappedFields,
- } as Partial;
- }, [propertyFilterQuery, usersData, projectsData]);
+ } as TEventListFilters;
+ }, [propertyFilterQuery, usersData, projectsData, permanentFilters]);
return {
filteringRequestParams,
@@ -299,5 +364,6 @@ export const useFilters = () => {
onChangePropertyFilter,
filteringOptions,
filteringProperties,
+ isLoadingFilters: isLoadingProjects || isLoadingUsers,
} as const;
};
diff --git a/frontend/src/pages/Events/List/index.tsx b/frontend/src/pages/Events/List/index.tsx
index fcf979d554..bfed27f558 100644
--- a/frontend/src/pages/Events/List/index.tsx
+++ b/frontend/src/pages/Events/List/index.tsx
@@ -1,7 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table } from 'components';
+import { Loader, PropertyFilter, Table } from 'components';
+import { TableProps } from 'components';
import { DEFAULT_TABLE_PAGE_SIZE } from 'consts';
import { useBreadcrumbs, useInfiniteScroll } from 'hooks';
@@ -14,7 +15,25 @@ import { useFilters } from './hooks/useFilters';
import styles from '../../Runs/List/styles.module.scss';
-export const EventList = () => {
+type RenderHeaderArgs = {
+ refreshAction?: () => void;
+ disabledRefresh?: boolean;
+};
+
+type EventListProps = Pick & {
+ withSearchParams?: boolean;
+ renderHeader?: (args: RenderHeaderArgs) => React.ReactNode;
+ permanentFilters?: Partial;
+ showFilters?: boolean;
+};
+
+export const EventList: React.FC = ({
+ withSearchParams,
+ permanentFilters,
+ renderHeader,
+ showFilters = true,
+ ...props
+}) => {
const { t } = useTranslation();
useBreadcrumbs([
@@ -24,12 +43,19 @@ export const EventList = () => {
},
]);
- const { filteringRequestParams, propertyFilterQuery, onChangePropertyFilter, filteringOptions, filteringProperties } =
- useFilters();
+ const {
+ filteringRequestParams,
+ propertyFilterQuery,
+ onChangePropertyFilter,
+ filteringOptions,
+ filteringProperties,
+ isLoadingFilters,
+ } = useFilters({ permanentFilters, withSearchParams });
const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({
useLazyQuery: useLazyGetAllEventsQuery,
args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE },
+ skip: isLoadingFilters,
getPaginationParams: (lastEvent) => ({
prev_recorded_at: lastEvent.recorded_at,
@@ -47,52 +73,39 @@ export const EventList = () => {
const { columns } = useColumnsDefinitions();
+ const loading = isLoadingFilters || isLoading;
+
return (
-
-
- }
- >
- {t('navigation.events')}
-
- }
+ header={renderHeader?.({ refreshAction: refreshList, disabledRefresh: loading })}
filter={
-
-
+ )
}
footer={
}
/>
diff --git a/frontend/src/pages/Events/index.tsx b/frontend/src/pages/Events/index.tsx
index 90105f2580..7171443bc1 100644
--- a/frontend/src/pages/Events/index.tsx
+++ b/frontend/src/pages/Events/index.tsx
@@ -1 +1 @@
-export { EventList } from './List';
+export { ListPage as EventList } from './List/ListPage';
diff --git a/frontend/src/pages/Project/Details/Events/index.tsx b/frontend/src/pages/Project/Details/Events/index.tsx
new file mode 100644
index 0000000000..df18300c52
--- /dev/null
+++ b/frontend/src/pages/Project/Details/Events/index.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { Button, Header, SpaceBetween } from 'components';
+
+import { useBreadcrumbs } from 'hooks';
+import { ROUTES } from 'routes';
+
+import { EventList } from 'pages/Events/List';
+
+export const Events: React.FC = () => {
+ const { t } = useTranslation();
+ const params = useParams();
+ const paramProjectName = params.projectName ?? '';
+ const navigate = useNavigate();
+
+ useBreadcrumbs([
+ {
+ text: t('navigation.project_other'),
+ href: ROUTES.PROJECT.LIST,
+ },
+ {
+ text: paramProjectName,
+ href: ROUTES.PROJECT.DETAILS.FORMAT(paramProjectName),
+ },
+ {
+ text: t('projects.events'),
+ href: ROUTES.PROJECT.DETAILS.EVENTS.FORMAT(paramProjectName),
+ },
+ ]);
+
+ const goToEventsPage = () => {
+ navigate(ROUTES.EVENTS.LIST + `?within_projects=${paramProjectName}`);
+ };
+
+ return (
+
{
+ return (
+