diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bbc05aef..4f5c18bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/security/cvm-boundaries.md b/docs/security/cvm-boundaries.md index 8c4380c23..df369899b 100644 --- a/docs/security/cvm-boundaries.md +++ b/docs/security/cvm-boundaries.md @@ -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. | diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index 0e52e878e..a0ce437e2 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -533,6 +533,10 @@ pub struct Requirements { /// on chain. #[serde(default, skip_serializing_if = "Option::is_none")] pub health_status_file: Option, + /// 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 { @@ -543,6 +547,7 @@ impl Requirements { && self.gpu_policy.is_default() && !self.health_check && self.health_status_file.is_none() + && !self.ephemeral_data_disk } } @@ -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, diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 2aa9df286..114e8d37e 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -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"); @@ -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" }; diff --git a/sdk/go/dstack/compose_hash.go b/sdk/go/dstack/compose_hash.go index 712f212bc..9f27f3a0e 100644 --- a/sdk/go/dstack/compose_hash.go +++ b/sdk/go/dstack/compose_hash.go @@ -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. diff --git a/sdk/go/dstack/compose_hash_ephemeral_test.go b/sdk/go/dstack/compose_hash_ephemeral_test.go new file mode 100644 index 000000000..7719c59d3 --- /dev/null +++ b/sdk/go/dstack/compose_hash_ephemeral_test.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: © 2025 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) + } +} diff --git a/sdk/js/src/get-compose-hash.ts b/sdk/js/src/get-compose-hash.ts index ba3e9fe1b..4148433ba 100644 --- a/sdk/js/src/get-compose-hash.ts +++ b/sdk/js/src/get-compose-hash.ts @@ -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; } diff --git a/sdk/python/src/dstack_sdk/get_compose_hash.py b/sdk/python/src/dstack_sdk/get_compose_hash.py index 9499c3051..2a33e1776 100644 --- a/sdk/python/src/dstack_sdk/get_compose_hash.py +++ b/sdk/python/src/dstack_sdk/get_compose_hash.py @@ -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.""" @@ -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]: @@ -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) diff --git a/sdk/python/tests/test_get_compose_hash.py b/sdk/python/tests/test_get_compose_hash.py index b73e26f90..fcb6c5429 100644 --- a/sdk/python/tests/test_get_compose_hash.py +++ b/sdk/python/tests/test_get_compose_hash.py @@ -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