Skip to content

Commit 5ffb0f3

Browse files
committed
fix(volume): preserve compose compatibility and enforce invariants
1 parent b657585 commit 5ffb0f3

6 files changed

Lines changed: 94 additions & 24 deletions

File tree

dstack/crates/dstack-cli/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ async fn cmd_deploy(
420420
.iter()
421421
.map(|s| parse_volume(s))
422422
.collect::<Result<Vec<_>>>()?;
423+
dstack_types::validate_verity_volumes(&parsed_volumes).map_err(anyhow::Error::msg)?;
423424

424425
// each --volume declares a measured verity_volumes entry, so the built
425426
// app-compose (and thus app_id) binds the attested roots.

dstack/crates/dstack-volume/src/bin/dstack-volume.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ fn prepare_volumes() -> Result<Vec<VerityVolume>> {
8686

8787
fn mount_all(compose_path: PathBuf) -> Result<()> {
8888
let compose = read_compose(&compose_path)?;
89+
dstack_types::validate_verity_volumes(&compose.verity_volumes).map_err(anyhow::Error::msg)?;
8990
if compose.verity_volumes.is_empty() {
9091
return Ok(());
9192
}

dstack/dstack-types/src/lib.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,29 @@ pub struct VerityVolume {
141141
pub target: std::path::PathBuf,
142142
}
143143

144+
/// Reject ambiguous volume declarations before any disk is attached or
145+
/// activated. A root identifies one logical volume, and a mount target can
146+
/// only be owned by one volume.
147+
pub fn validate_verity_volumes(volumes: &[VerityVolume]) -> Result<(), String> {
148+
let mut roots = std::collections::HashSet::new();
149+
let mut targets = std::collections::HashSet::new();
150+
for volume in volumes {
151+
if !roots.insert(volume.verity_root) {
152+
return Err(format!(
153+
"duplicate verity root {}",
154+
hex::encode(volume.verity_root)
155+
));
156+
}
157+
if !targets.insert(&volume.target) {
158+
return Err(format!(
159+
"duplicate verity volume target {}",
160+
volume.target.display()
161+
));
162+
}
163+
}
164+
Ok(())
165+
}
166+
144167
fn deserialize_absolute_path<'de, D>(deserializer: D) -> Result<std::path::PathBuf, D::Error>
145168
where
146169
D: serde::Deserializer<'de>,
@@ -155,6 +178,30 @@ where
155178
Ok(path)
156179
}
157180

181+
#[cfg(test)]
182+
mod verity_volume_tests {
183+
use super::{validate_verity_volumes, VerityVolume};
184+
185+
fn volume(root: u8, target: &str) -> VerityVolume {
186+
VerityVolume {
187+
source: format!("{root}.img"),
188+
verity_root: [root; 32],
189+
target: target.into(),
190+
}
191+
}
192+
193+
#[test]
194+
fn rejects_duplicate_roots_and_targets() {
195+
assert!(validate_verity_volumes(&[volume(1, "/a"), volume(1, "/b")])
196+
.unwrap_err()
197+
.contains("duplicate verity root"));
198+
assert!(validate_verity_volumes(&[volume(1, "/a"), volume(2, "/a")])
199+
.unwrap_err()
200+
.contains("duplicate verity volume target"));
201+
validate_verity_volumes(&[volume(1, "/a"), volume(2, "/b")]).unwrap();
202+
}
203+
}
204+
158205
/// Canonical source for the policy used when `requirements.gpu_policy` is
159206
/// absent. Both typed defaults and measurement are derived from this JSON.
160207
pub const DEFAULT_GPU_POLICY: &str = "{}";

dstack/vmm/src/app.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,6 @@ pub struct PortMapping {
8585
#[derive(Deserialize, Serialize, Debug, Clone)]
8686
pub struct VmVolume {
8787
pub source: String,
88-
#[serde(default)]
89-
pub read_only: bool,
9088
}
9189

9290
#[derive(Deserialize, Serialize, Clone, Builder, Debug)]
@@ -1741,11 +1739,9 @@ mod tests {
17411739
manifest.volumes = vec![
17421740
VmVolume {
17431741
source: "/volumes/a.img".into(),
1744-
read_only: true,
17451742
},
17461743
VmVolume {
17471744
source: "/volumes/b.img".into(),
1748-
read_only: true,
17491745
},
17501746
];
17511747
let vm_config = make_vm_config(

dstack/vmm/src/app/qemu.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,6 @@ fn virtio_pci_device(device: &str, snp: bool) -> String {
176176

177177
struct PreparedVolume {
178178
source: String,
179-
read_only: bool,
180179
}
181180

182181
struct PreparedQemuLaunch {
@@ -214,7 +213,6 @@ impl PreparedQemuLaunch {
214213
.iter()
215214
.map(|volume| PreparedVolume {
216215
source: volume.source.clone(),
217-
read_only: volume.read_only,
218216
})
219217
.collect();
220218

@@ -550,10 +548,10 @@ impl QemuCommandBuilder<'_> {
550548
// established device order.
551549
for (index, volume) in self.prepared.volumes.iter().enumerate() {
552550
let id = format!("vol{index}");
553-
let mut drive = format!("file={},if=none,id={id},format=raw", volume.source);
554-
if volume.read_only {
555-
drive.push_str(",readonly=on");
556-
}
551+
let drive = format!(
552+
"file={},if=none,id={id},format=raw,readonly=on",
553+
volume.source
554+
);
557555

558556
let device = format!("virtio-blk-pci,drive={id}");
559557
command
@@ -1048,7 +1046,6 @@ mod tests {
10481046
networks: vec![],
10491047
volumes: vec![VmVolume {
10501048
source: "/does-not-exist/volume.img".into(),
1051-
read_only: true,
10521049
}],
10531050
},
10541051
image: Image {
@@ -1086,7 +1083,6 @@ mod tests {
10861083
networks: vec![config.cvm.networking.clone(), config.cvm.networking.clone()],
10871084
volumes: vec![PreparedVolume {
10881085
source: "/does-not-exist/volume.img".into(),
1089-
read_only: true,
10901086
}],
10911087
hugepage_numa_nodes: None,
10921088
gpu_numa_nodes: HashMap::new(),

dstack/vmm/src/main_service.rs

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ pub fn create_manifest_from_vm_config(
184184
Some(gpus) => resolve_gpus_with_config(gpus, cvm_config)?,
185185
None => GpuConfig::default(),
186186
};
187-
let app_compose: AppCompose = serde_json::from_str(&request.compose_file)
188-
.context("invalid app-compose in VM configuration")?;
189-
let volumes = resolve_volumes(&app_compose.verity_volumes, cvm_config)?;
187+
let verity_volumes = extract_verity_volumes(&request.compose_file)?;
188+
dstack_types::validate_verity_volumes(&verity_volumes).map_err(anyhow::Error::msg)?;
189+
let volumes = resolve_volumes(&verity_volumes, cvm_config)?;
190190

191191
Ok(Manifest {
192192
id,
@@ -209,6 +209,19 @@ pub fn create_manifest_from_vm_config(
209209
})
210210
}
211211

212+
/// Extract only the field understood by this VMM. Keep every other app-compose
213+
/// field opaque so newer guest schemas and legacy third-party clients remain
214+
/// compatible with older VMMs.
215+
fn extract_verity_volumes(compose: &str) -> Result<Vec<dstack_types::VerityVolume>> {
216+
let Ok(compose) = serde_json::from_str::<serde_json::Value>(compose) else {
217+
return Ok(vec![]);
218+
};
219+
let Some(volumes) = compose.get("verity_volumes") else {
220+
return Ok(vec![]);
221+
};
222+
serde_json::from_value(volumes.clone()).context("invalid verity_volumes in app-compose")
223+
}
224+
212225
/// Resolve requested volumes against `cvm.volumes_dir`. Each `source` must be a
213226
/// bare file name under that directory; the host attaches the bytes, and the
214227
/// guest verifies content against the measured `verity_root`.
@@ -229,11 +242,6 @@ fn resolve_volumes(
229242
let real = resolve_volume_source(&base, &v.source)?;
230243
Ok(crate::app::VmVolume {
231244
source: real.to_string_lossy().into_owned(),
232-
// Verity volumes are always read-only: the backing file is shared
233-
// content-addressed data, so a writable attach could only let one
234-
// guest corrupt it for every other tenant. Force it regardless of
235-
// what the client asked for.
236-
read_only: true,
237245
})
238246
})
239247
.collect()
@@ -905,8 +913,7 @@ mod tests {
905913
VmConfiguration {
906914
name: "vm-test".to_string(),
907915
image: "dstack-test".to_string(),
908-
compose_file: r#"{"manifest_version":2,"name":"test","runner":"docker-compose"}"#
909-
.to_string(),
916+
compose_file: "{}".to_string(),
910917
vcpu: 1,
911918
memory: 1024,
912919
disk_size: 10,
@@ -934,6 +941,25 @@ mod tests {
934941
assert!(manifest.networks.is_empty());
935942
}
936943

944+
#[test]
945+
fn volume_extraction_keeps_other_compose_fields_opaque() -> Result<()> {
946+
assert!(extract_verity_volumes("not json")?.is_empty());
947+
assert!(extract_verity_volumes(r#"{"future_manifest":true}"#)?.is_empty());
948+
949+
let compose = serde_json::json!({
950+
"unknown_future_field": { "anything": true },
951+
"verity_volumes": [{
952+
"source": "volume.img",
953+
"verity_root": "11".repeat(32),
954+
"target": "/run/volume"
955+
}]
956+
});
957+
let volumes = extract_verity_volumes(&compose.to_string())?;
958+
assert_eq!(volumes.len(), 1);
959+
assert_eq!(volumes[0].verity_root, [0x11; 32]);
960+
Ok(())
961+
}
962+
937963
#[test]
938964
fn explicit_user_networking_is_resolved_before_persist() {
939965
let mut request = test_vm_configuration();
@@ -1009,7 +1035,7 @@ mod tests {
10091035
}
10101036

10111037
#[test]
1012-
fn resolve_volumes_forces_read_only() -> Result<()> {
1038+
fn resolve_volumes_resolves_measured_source() -> Result<()> {
10131039
let tmp = tempfile::tempdir()?;
10141040
fs::write(tmp.path().join("volume.img"), b"volume")?;
10151041
let mut cvm_config = test_cvm_config();
@@ -1025,7 +1051,10 @@ mod tests {
10251051
)?;
10261052

10271053
assert_eq!(volumes.len(), 1);
1028-
assert!(volumes[0].read_only);
1054+
assert_eq!(
1055+
volumes[0].source,
1056+
tmp.path().join("volume.img").display().to_string()
1057+
);
10291058
Ok(())
10301059
}
10311060
}

0 commit comments

Comments
 (0)