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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- guest: `requirements.ephemeral_data_disk` in `app-compose.json` starts every boot with an empty data disk. The guest formats the data disk before any app code runs (#1461)

### Fixed
- guest: a fresh CVM with a verity volume could take the volume for its data disk and try to LUKS-format it: volumes labelled their data partition `dstack-data`, the data disk's label. `dstack verity` now labels it `dstack-volume-data`, and the guest skips disks that carry a `dstack-volume` partition, so volumes built by older releases are safe too

Expand Down
1 change: 1 addition & 0 deletions docs/security/cvm-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ This is the main configuration file for the application in JSON format:
| init_script | 0.5.5 (string), 0.6.0 (string[]) | string or string[] | Up to 5 Bash scripts executed in order prior to dockerd startup, so they always complete before any container starts, on every boot; a string is treated as a one-element array. Multiple scripts require string `manifest_version: "3"` so older guests fail closed. MrConfigV3 binds the hashes only for manifest v3. |
| storage_fs | 0.5.5 | string | Filesystem type for the data disk of the CVM. Supported values: "zfs", "ext4". default to "zfs". **ZFS:** Checksums every block, so a modified block fails the read. **ext4:** Lower overhead and faster I/O for database workloads; checksums metadata but not file data, so a modified block is not detected. |
| storage_discard | 0.6.0 | boolean | Return unused data-disk blocks to the host. Defaults to `true` so encrypted sparse images track live data instead of historical writes. Set to `false` if revealing filesystem allocation and deletion patterns to the host is unacceptable. |
| requirements.ephemeral_data_disk | 0.6.2 | boolean | Start every boot with an empty data disk. The guest formats the data disk before any app code runs, so app code sees nothing an earlier boot wrote. With storage encryption on (the default), each format generates a fresh LUKS volume key, so earlier ciphertext, including a disk image the host rolled back, can no longer be decrypted. With encryption off, old blocks may remain readable on the raw device. Images and container layers live on the data disk, so every boot pulls them again. Defaults to `false`. Requires string `manifest_version: "3"`. |
| swap_size | 0.5.5 | string/integer | The linux swap size. default to 0. Can be in byte or human-readable format (e.g., "1G", "256M"). |
| key_provider | 0.5.6 | string | Key provider type. Supported values: "none", "kms", "local", "tpm". GCP vTPM and AWS EC2 NitroTPM are part of their platform trust models. The Dstack platform can use VMM-managed swtpm for seal/unseal and restart persistence, but it offers no protection against the host and is intentionally not accepted by remote verifiers. |

Expand Down
11 changes: 11 additions & 0 deletions dstack/dstack-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,10 @@ pub struct Requirements {
/// on chain.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_status_file: Option<String>,
/// Start every boot with an empty data disk. The guest formats the data
/// disk before any app code runs, discarding what earlier boots left on it.
#[serde(default, skip_serializing_if = "is_false")]
pub ephemeral_data_disk: bool,
}

impl Requirements {
Expand All @@ -543,6 +547,7 @@ impl Requirements {
&& self.gpu_policy.is_default()
&& !self.health_check
&& self.health_status_file.is_none()
&& !self.ephemeral_data_disk
}
}

Expand Down Expand Up @@ -801,6 +806,12 @@ impl AppCompose {
self.key_provider().is_kms()
}

pub fn ephemeral_data_disk(&self) -> bool {
self.requirements
.as_ref()
.is_some_and(|requirements| requirements.ephemeral_data_disk)
}

pub fn key_provider(&self) -> KeyProviderKind {
match self.key_provider {
Some(p) => p,
Expand Down
11 changes: 8 additions & 3 deletions dstack/dstack-util/src/system_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2648,7 +2648,11 @@ impl<'a> Stage0<'a> {

cmd!(mkdir -p $mount_point).context("Failed to create mount point")?;

let disk_initialized = self.is_disk_initialized(opts);
let ephemeral = self.shared.app_compose.ephemeral_data_disk();
if ephemeral {
info!("discarding previous data disk contents (requirements.ephemeral_data_disk)");
}
let disk_initialized = !ephemeral && self.is_disk_initialized(opts);

let has_filesystem = if opts.storage_encrypted && disk_initialized {
info!("Mounting encrypted data disk");
Expand Down Expand Up @@ -2677,8 +2681,9 @@ impl<'a> Stage0<'a> {
match opts.storage_fs {
FsType::Zfs => {
info!("Creating ZFS filesystem");
if disk_initialized {
// The interrupted attempt may have left a pool label behind.
if disk_initialized || ephemeral {
// An interrupted attempt or a previous boot may have
// left a pool label behind.
cmd!(zpool labelclear -f $fs_dev).ok();
}
let autotrim = if opts.storage_discard { "on" } else { "off" };
Expand Down
4 changes: 4 additions & 0 deletions sdk/go/dstack/compose_hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ type Requirements struct {
// pointer for the same reason.
HealthStatusFile *string `json:"health_status_file,omitempty"`

// EphemeralDataDisk formats the data disk on every boot. Like HealthCheck,
// a false value must stay out of the manifest.
EphemeralDataDisk bool `json:"ephemeral_data_disk,omitempty"`

GpuPolicy *GpuPolicy `json:"gpu_policy,omitempty"`

// Extra carries fields this SDK does not know; see AppCompose.Extra.
Expand Down
22 changes: 22 additions & 0 deletions sdk/go/dstack/compose_hash_ephemeral_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: © 2025 Phala Network <dstack@phala.network>
//
// SPDX-License-Identifier: Apache-2.0

package dstack

import "testing"

func TestDisabledEphemeralDataDiskHashesAsAbsent(t *testing.T) {
disabled := AppCompose{
Runner: "docker-compose",
Requirements: &Requirements{LaunchTokenHash: "ff00", EphemeralDataDisk: false},
}
got, err := GetComposeHash(disabled)
if err != nil {
t.Fatal(err)
}
want := hashOfRawJSON(t, `{"runner":"docker-compose","requirements":{"launch_token_hash":"ff00"}}`)
if got != want {
t.Fatalf("a false ephemeral_data_disk must not appear in the manifest.\n got=%s\nwant=%s", got, want)
}
}
2 changes: 2 additions & 0 deletions sdk/js/src/get-compose-hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export interface Requirements extends SortableObject {
* different app composes and hash differently, in every SDK.
*/
health_status_file?: string;
/** Format the data disk on every boot. Leave it out rather than passing `false`. */
ephemeral_data_disk?: boolean;
gpu_policy?: GpuPolicy;
}

Expand Down
4 changes: 4 additions & 0 deletions sdk/python/src/dstack_sdk/get_compose_hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def __init__(
health_check: bool = False,
health_status_file: Optional[str] = None,
gpu_policy: Optional[Dict[str, Any]] = None,
ephemeral_data_disk: bool = False,
**kwargs: Any,
) -> None:
"""Initialize a new ``Requirements`` instance."""
Expand All @@ -69,6 +70,7 @@ def __init__(
self.health_check = health_check
self.health_status_file = health_status_file
self.gpu_policy = gpu_policy
self.ephemeral_data_disk = ephemeral_data_disk
self._extra = dict(kwargs)

def to_dict(self) -> Dict[str, Any]:
Expand All @@ -89,6 +91,8 @@ def to_dict(self) -> Dict[str, Any]:
# composes, so they must be different hashes in every SDK.
if self.health_status_file is not None:
result["health_status_file"] = self.health_status_file
if self.ephemeral_data_disk:
result["ephemeral_data_disk"] = True
if self.gpu_policy is not None:
result["gpu_policy"] = self.gpu_policy
result.update(self._extra)
Expand Down
15 changes: 15 additions & 0 deletions sdk/python/tests/test_get_compose_hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,21 @@ def test_health_check_off_hashes_as_if_it_were_absent():
assert get_compose_hash(absent) == get_compose_hash(disabled)


def test_ephemeral_data_disk_off_hashes_as_if_it_were_absent():
"""The guest's Rust types skip a false `ephemeral_data_disk`."""
from dstack_sdk.get_compose_hash import Requirements

absent = AppCompose(
runner="docker-compose", requirements=Requirements(launch_token_hash="ff00")
)
disabled = AppCompose(
runner="docker-compose",
requirements=Requirements(launch_token_hash="ff00", ephemeral_data_disk=False),
)

assert get_compose_hash(absent) == get_compose_hash(disabled)


def test_health_status_file_is_part_of_the_hash():
"""`health_status_file` selects the verdict source, so it is not cosmetic."""
from dstack_sdk.get_compose_hash import Requirements
Expand Down
Loading