Skip to content

Commit aa47b6b

Browse files
committed
refactor(volume): configure attachments in app compose
1 parent b22f575 commit aa47b6b

11 files changed

Lines changed: 125 additions & 82 deletions

File tree

docs/verity-volumes.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ This adds the following measured entry to `app-compose.json`:
4747
{
4848
"verity_volumes": [
4949
{
50+
"source": "models.img",
5051
"verity_root": "a1b2c3d4...",
5152
"target": "/run/models"
5253
}
@@ -59,7 +60,7 @@ The target must be an absolute path on a writable guest filesystem, such as
5960

6061
## Guest activation
6162

62-
Before the application starts, `dstack-volume`:
63+
Before the application starts, `dstack-volume mount-all app-compose.json`:
6364

6465
1. Scans `/sys/class/block` for disks whose first partition, or whole disk when
6566
unpartitioned, starts with the `DSTACK_VOLUME` magic.
@@ -72,6 +73,11 @@ A required volume that is missing, malformed, or fails verification stops guest
7273
preparation. dm-verity continues verifying blocks lazily as the application
7374
reads them.
7475

76+
For diagnostics, `dstack-volume scan` lists recognized disks and
77+
`dstack-volume status app-compose.json` compares requested roots with attached
78+
and active devices. A single entry can be activated with
79+
`dstack-volume mount app-compose.json INDEX`.
80+
7581
## Trust and limitations
7682

7783
The root hash authenticates bytes, not availability. A malicious host can omit a

dstack/crates/dstack-cli-core/src/compose.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ use serde_json::json;
1313
/// `kms_enabled` selects KMS mode (deterministic, upgradeable per-app keys);
1414
/// gateway and local-key-provider are off for the direct-port single-node flow.
1515
///
16-
/// `verity_volumes` is a list of `(verity_root, target)` pairs. Each becomes a
16+
/// `verity_volumes` is a list of `(source, verity_root, target)` tuples. Each becomes a
1717
/// measured `verity_volumes` entry, so the CVM only seeds content matching the
1818
/// attested root. Empty for a normal deploy.
1919
pub fn build_app_compose(
2020
name: &str,
2121
docker_compose_yaml: &str,
2222
kms_enabled: bool,
23-
verity_volumes: &[(String, String)],
23+
verity_volumes: &[(String, String, String)],
2424
) -> String {
2525
let mut manifest = json!({
2626
"manifest_version": 2,
@@ -43,7 +43,7 @@ pub fn build_app_compose(
4343
if !verity_volumes.is_empty() {
4444
manifest["verity_volumes"] = json!(verity_volumes
4545
.iter()
46-
.map(|(root, target)| json!({ "verity_root": root, "target": target }))
46+
.map(|(source, root, target)| json!({ "source": source, "verity_root": root, "target": target }))
4747
.collect::<Vec<_>>());
4848
}
4949
// pretty-print via Value's Display (`{:#}`) — infallible, and byte-identical

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

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,9 @@ async fn cmd_verity(
290290
Ok(())
291291
}
292292

293-
/// A parsed `--volume` spec: the disk to attach plus its measured verity entry.
293+
/// A parsed `--volume` spec. All fields become part of measured app-compose.
294294
struct VolumeSpec {
295-
volume: rpc::VmVolume,
295+
source: String,
296296
verity_root: String,
297297
target: String,
298298
}
@@ -327,12 +327,7 @@ fn parse_volume(spec: &str) -> Result<VolumeSpec> {
327327
bail!("target '{target}' must be an absolute path");
328328
}
329329
Ok(VolumeSpec {
330-
// verity volumes are read-only by construction; a writable one would let a
331-
// guest corrupt the shared backing file (the vmm also forces this).
332-
volume: rpc::VmVolume {
333-
source: name.to_string(),
334-
read_only: true,
335-
},
330+
source: name.to_string(),
336331
verity_root: root.to_string(),
337332
target: target.to_string(),
338333
})
@@ -430,12 +425,11 @@ async fn cmd_deploy(
430425

431426
// each --volume declares a measured verity_volumes entry, so the built
432427
// app-compose (and thus app_id) binds the attested roots.
433-
let verity_volumes: Vec<(String, String)> = parsed_volumes
428+
let verity_volumes: Vec<(String, String, String)> = parsed_volumes
434429
.iter()
435-
.map(|v| (v.verity_root.clone(), v.target.clone()))
430+
.map(|v| (v.source.clone(), v.verity_root.clone(), v.target.clone()))
436431
.collect();
437432
let app_compose = compose::build_app_compose(name, &yaml, !no_kms, &verity_volumes);
438-
let volumes: Vec<_> = parsed_volumes.into_iter().map(|v| v.volume).collect();
439433

440434
let mut cfg = rpc::VmConfiguration {
441435
name: name.to_string(),
@@ -445,7 +439,6 @@ async fn cmd_deploy(
445439
memory,
446440
disk_size: disk,
447441
ports: port_maps.clone(),
448-
volumes,
449442
..Default::default()
450443
};
451444

@@ -675,8 +668,7 @@ mod tests {
675668
fn parses_volume_specs() {
676669
let root = "a".repeat(64);
677670
let data = parse_volume(&format!("weights.img:{root}:/models/llama")).unwrap();
678-
assert_eq!(data.volume.source, "weights.img");
679-
assert!(data.volume.read_only);
671+
assert_eq!(data.source, "weights.img");
680672
assert_eq!(data.verity_root, root);
681673
assert_eq!(data.target, "/models/llama");
682674

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

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,22 +41,58 @@ struct VerityVolume {
4141

4242
fn main() -> Result<()> {
4343
tracing_subscriber::fmt().init();
44-
let compose_path = std::env::args_os()
45-
.nth(1)
46-
.map(PathBuf::from)
47-
.unwrap_or_else(|| PathBuf::from("app-compose.json"));
44+
let mut args = std::env::args_os().skip(1);
45+
let command = args
46+
.next()
47+
.context(
48+
"usage: dstack-volume <mount-all [COMPOSE] | mount COMPOSE INDEX | scan | status [COMPOSE]>",
49+
)?;
50+
match command.to_str() {
51+
Some("mount-all") => mount_all(
52+
args.next()
53+
.map(PathBuf::from)
54+
.unwrap_or_else(|| PathBuf::from("app-compose.json")),
55+
),
56+
Some("mount") => {
57+
let compose = args.next().context("mount requires COMPOSE and INDEX")?;
58+
let index = args
59+
.next()
60+
.context("mount requires COMPOSE and INDEX")?
61+
.to_str()
62+
.context("INDEX is not UTF-8")?
63+
.parse()
64+
.context("invalid volume INDEX")?;
65+
mount_one(PathBuf::from(compose), index)
66+
}
67+
Some("scan") => scan(),
68+
Some("status") => status(
69+
args.next()
70+
.map(PathBuf::from)
71+
.unwrap_or_else(|| PathBuf::from("app-compose.json")),
72+
),
73+
_ => bail!("unknown command {:?}", command),
74+
}
75+
}
76+
77+
fn read_compose(compose_path: &Path) -> Result<AppCompose> {
4878
// Deserialize the complete shared type before probing any untrusted disk.
4979
// This validates roots and targets as part of parsing the measured compose.
50-
let compose: AppCompose = serde_json::from_slice(&fs::read(&compose_path)?)
51-
.with_context(|| format!("parsing {}", compose_path.display()))?;
52-
if compose.verity_volumes.is_empty() {
53-
return Ok(());
54-
}
80+
serde_json::from_slice(&fs::read(compose_path)?)
81+
.with_context(|| format!("parsing {}", compose_path.display()))
82+
}
5583

84+
fn prepare_volumes() -> Result<Vec<VerityVolume>> {
5685
let _ = run_cmd!(modprobe dm-verity);
5786
let _ = run_cmd!(udevadm settle --timeout=5);
87+
discover_volumes()
88+
}
5889

59-
let volumes = discover_volumes()?;
90+
fn mount_all(compose_path: PathBuf) -> Result<()> {
91+
let compose = read_compose(&compose_path)?;
92+
if compose.verity_volumes.is_empty() {
93+
return Ok(());
94+
}
95+
let volumes = prepare_volumes()?;
6096
info!(
6197
found = volumes.len(),
6298
requested = compose.verity_volumes.len(),
@@ -74,6 +110,53 @@ fn main() -> Result<()> {
74110
Ok(())
75111
}
76112

113+
fn mount_one(compose_path: PathBuf, index: usize) -> Result<()> {
114+
let compose = read_compose(&compose_path)?;
115+
let requested = compose
116+
.verity_volumes
117+
.get(index)
118+
.with_context(|| format!("volume index {index} is out of range"))?;
119+
let volumes = prepare_volumes()?;
120+
activate_requested(index, requested, &volumes, &mut HashSet::new()).with_context(|| {
121+
format!(
122+
"failed to activate required volume {index} at {}",
123+
requested.target.display()
124+
)
125+
})
126+
}
127+
128+
fn scan() -> Result<()> {
129+
for volume in prepare_volumes()? {
130+
println!(
131+
"{}\tdata={}\thash={}",
132+
hex::encode(volume.root_hash),
133+
volume.data.display(),
134+
volume.hash.display()
135+
);
136+
}
137+
Ok(())
138+
}
139+
140+
fn status(compose_path: PathBuf) -> Result<()> {
141+
let compose = read_compose(&compose_path)?;
142+
let volumes = prepare_volumes()?;
143+
for (index, requested) in compose.verity_volumes.iter().enumerate() {
144+
let attached = volumes
145+
.iter()
146+
.any(|volume| volume.root_hash == requested.verity_root);
147+
let mapper_name = format!("dstack-verity{index}");
148+
let active = Path::new("/dev/mapper").join(&mapper_name).exists()
149+
&& mapping_root(&mapper_name)?
150+
.eq_ignore_ascii_case(&hex::encode(requested.verity_root));
151+
println!(
152+
"{index}\troot={}\ttarget={}\tattached={attached}\tactive={active}",
153+
hex::encode(requested.verity_root),
154+
requested.target.display()
155+
);
156+
}
157+
Ok(())
158+
}
159+
77160
fn discover_volumes() -> Result<Vec<VerityVolume>> {
78161
let mut found = Vec::new();
79162
let disks = list_disks()?;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ mod tests {
6565
fn verity_volume_validates_root_and_target_during_deserialization(
6666
) -> Result<(), serde_json::Error> {
6767
let volume: VerityVolume = serde_json::from_value(serde_json::json!({
68+
"source": "models.img",
6869
"verity_root": "5a".repeat(32),
6970
"target": "/run/models"
7071
}))?;
@@ -76,11 +77,13 @@ mod tests {
7677
);
7778

7879
assert!(serde_json::from_value::<VerityVolume>(serde_json::json!({
80+
"source": "models.img",
7981
"verity_root": "abcd",
8082
"target": "/run/models"
8183
}))
8284
.is_err());
8385
assert!(serde_json::from_value::<VerityVolume>(serde_json::json!({
86+
"source": "models.img",
8487
"verity_root": "5a".repeat(32),
8588
"target": "relative/path"
8689
}))

dstack/dstack-types/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ pub struct AppCompose {
130130
/// A pre-baked, read-only dm-verity volume attached to the CVM.
131131
#[derive(Deserialize, Serialize, Debug, Clone)]
132132
pub struct VerityVolume {
133+
/// Bare image file name resolved by the VMM under `cvm.volumes_dir`.
134+
pub source: String,
133135
/// dm-verity root hash (hex): the volume's content identity and integrity
134136
/// check. The guest matches attached devices against it.
135137
#[serde(with = "hex_bytes")]

dstack/vmm/rpc/proto/vmm_rpc.proto

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,17 +118,7 @@ message VmConfiguration {
118118
optional NetworkingConfig networking = 18;
119119
// Per-VM networking overrides. If set, wins over singular networking.
120120
repeated NetworkingConfig networks = 19;
121-
// Extra volumes to attach (e.g. pre-baked verity volumes). The host only
122-
// supplies bytes; the guest verifies content against the measured verity_root.
123-
repeated VmVolume volumes = 20;
124-
}
125-
126-
// An extra disk attached to the VM, such as a pre-baked verity volume.
127-
message VmVolume {
128-
// Volume file name, resolved against the vmm's configured cvm.volumes_dir.
129-
string source = 1;
130-
// Attach the disk read-only.
131-
bool read_only = 2;
121+
reserved 20;
132122
}
133123

134124
// Per-VM networking configuration.

dstack/vmm/src/app/vm_info.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,15 +169,6 @@ impl VmInfo {
169169
no_tee,
170170
networking: configured_networking,
171171
networks: configured_networks,
172-
volumes: self
173-
.manifest
174-
.volumes
175-
.iter()
176-
.map(|volume| pb::VmVolume {
177-
source: volume.source.clone(),
178-
read_only: volume.read_only,
179-
})
180-
.collect(),
181172
})
182173
},
183174
app_url: self

dstack/vmm/src/main_service.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +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 volumes = resolve_volumes(&request.volumes, cvm_config)?;
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)?;
188190

189191
Ok(Manifest {
190192
id,
@@ -211,7 +213,7 @@ pub fn create_manifest_from_vm_config(
211213
/// bare file name under that directory; the host attaches the bytes, and the
212214
/// guest verifies content against the measured `verity_root`.
213215
fn resolve_volumes(
214-
reqs: &[rpc::VmVolume],
216+
reqs: &[dstack_types::VerityVolume],
215217
cvm_config: &crate::config::CvmConfig,
216218
) -> Result<Vec<crate::app::VmVolume>> {
217219
if reqs.is_empty() {
@@ -903,7 +905,8 @@ mod tests {
903905
VmConfiguration {
904906
name: "vm-test".to_string(),
905907
image: "dstack-test".to_string(),
906-
compose_file: "{}".to_string(),
908+
compose_file: r#"{"manifest_version":2,"name":"test","runner":"docker-compose"}"#
909+
.to_string(),
907910
vcpu: 1,
908911
memory: 1024,
909912
disk_size: 10,
@@ -920,7 +923,6 @@ mod tests {
920923
no_tee: false,
921924
networking: None,
922925
networks: vec![],
923-
volumes: vec![],
924926
}
925927
}
926928

@@ -1014,9 +1016,10 @@ mod tests {
10141016
cvm_config.volumes_dir = tmp.path().to_string_lossy().into_owned();
10151017

10161018
let volumes = resolve_volumes(
1017-
&[rpc::VmVolume {
1019+
&[dstack_types::VerityVolume {
10181020
source: "volume.img".into(),
1019-
read_only: false,
1021+
verity_root: [0; 32],
1022+
target: "/run/volume".into(),
10201023
}],
10211024
&cvm_config,
10221025
)?;

0 commit comments

Comments
 (0)