Skip to content

Commit 8551c9a

Browse files
committed
dstackup: drop digest.sev.txt image pin
1 parent a2fbb67 commit 8551c9a

6 files changed

Lines changed: 102 additions & 47 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ services:
6767
- "8000:8000"
6868
```
6969
70-
Deploy to a self-hosted TDX machine with the `dstackup install` -> `dstack deploy` workflow, or use [Phala Cloud](https://cloud.phala.network) for managed infrastructure. AMD SEV-SNP hosts use the same workflow when the selected guest image includes `digest.sev.txt`.
70+
Deploy to a self-hosted TDX machine with the `dstackup install` -> `dstack deploy` workflow, or use [Phala Cloud](https://cloud.phala.network) for managed infrastructure. AMD SEV-SNP hosts use the same workflow when the selected guest image includes the unified `digest.txt` and SNP measurement material (`measurement.snp.cbor`).
7171

7272
Setting up dstack on your own hardware? Start with the [self-hosted quick onboarding guide](./docs/onboarding.md)
7373

crates/dstackup/src/cli.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,9 @@ pub(crate) struct InstallOpts {
223223
#[arg(long)]
224224
pub(crate) no_kms: bool,
225225

226-
/// proceed even if the app OS image can't be pinned (missing platform
227-
/// digest) — apps will boot any unmeasured image and still get keys. not
226+
/// proceed even if the app OS image can't be pinned in the host allowlist
227+
/// (for example, a missing digest on platforms that can still boot without
228+
/// it) — apps may boot an unmeasured image and still get keys. not
228229
/// recommended.
229230
#[arg(long)]
230231
pub(crate) allow_unpinned_image: bool,

crates/dstackup/src/image.rs

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
//!
77
//! Images are published as release tarballs at `Dstack-TEE/meta-dstack`. There
88
//! are two variants — cpu (`dstack-<ver>`) and gpu (`dstack-nvidia-<ver>`).
9-
//! `install` validates the selected image against the platform-specific digest
10-
//! it needs before starting the host stack: TDX uses `digest.txt`, and
11-
//! SEV-SNP uses `digest.sev.txt`. HTTP + checksum are native (reqwest is
12-
//! already linked via the prpc client; sha2 verifies inline); only `tar` is
13-
//! shelled out, since GNU tar is ubiquitous and battle-tested on archive edges.
9+
//! `install` validates the selected image against the platform-specific files
10+
//! it needs before starting the host stack: all platforms use `digest.txt` as
11+
//! the unified OS image hash, and SEV-SNP additionally needs
12+
//! `measurement.snp.cbor`. HTTP + checksum are native (reqwest is already
13+
//! linked via the prpc client; sha2 verifies inline); only `tar` is shelled out,
14+
//! since GNU tar is ubiquitous and battle-tested on archive edges.
1415
1516
use crate::cli::ImageCmd;
1617
use crate::systemd::tool;
@@ -364,7 +365,7 @@ pub(crate) async fn resolve_or_pull_image(
364365
image_dir: &str,
365366
requested: Option<&str>,
366367
require: bool,
367-
required_digest: Option<&str>,
368+
required_files: &[&str],
368369
) -> Result<Option<String>> {
369370
if let Some(name) = requested {
370371
if !valid_image_name(name) {
@@ -375,23 +376,29 @@ pub(crate) async fn resolve_or_pull_image(
375376
.join("metadata.json")
376377
.exists()
377378
{
379+
ensure_image_has_required_files(image_dir, name, required_files)?;
378380
return Ok(Some(name.to_string()));
379381
}
380382
if let Some(spec) = pull_spec(name) {
381383
println!(" [..] image {name} not found locally; downloading it");
382384
let pulled = pull(Some(&spec.version), spec.gpu, image_dir, false, false).await?;
385+
ensure_image_has_required_files(image_dir, &pulled, required_files)?;
383386
return Ok(Some(pulled));
384387
}
385-
return resolve_image(image_dir, Some(name), require);
388+
let resolved = resolve_image(image_dir, Some(name), require)?;
389+
if let Some(resolved) = &resolved {
390+
ensure_image_has_required_files(image_dir, resolved, required_files)?;
391+
}
392+
return Ok(resolved);
386393
}
387394

388395
let mut imgs = installed_images(image_dir);
389-
let skipped = retain_images_with_digest(&mut imgs, image_dir, required_digest);
396+
let skipped = retain_images_with_required_files(&mut imgs, image_dir, required_files);
390397
if let Some(newest) = imgs.pop() {
391398
if !skipped.is_empty() {
392399
println!(
393400
" [!] ignoring image(s) without {}: {}",
394-
required_digest.unwrap_or("required digest"),
401+
required_files_label(required_files),
395402
skipped.join(", ")
396403
);
397404
}
@@ -407,12 +414,16 @@ pub(crate) async fn resolve_or_pull_image(
407414
}
408415

409416
if !require {
410-
if let Some(digest) = required_digest {
417+
if !required_files.is_empty() {
411418
if skipped.is_empty() {
412-
println!(" [!] no guest image in {image_dir} with {digest} - `dstack deploy -c <compose>` will need one (`dstackup image pull`)");
419+
println!(
420+
" [!] no guest image in {image_dir} with {} - `dstack deploy -c <compose>` will need one (`dstackup image pull`)",
421+
required_files_label(required_files)
422+
);
413423
} else {
414424
println!(
415-
" [!] no guest image in {image_dir} with {digest}; ignored {} - `dstack deploy -c <compose>` will need one (`dstackup image pull`)",
425+
" [!] no guest image in {image_dir} with {}; ignored {} - `dstack deploy -c <compose>` will need one (`dstackup image pull`)",
426+
required_files_label(required_files),
416427
skipped.join(", ")
417428
);
418429
}
@@ -422,14 +433,16 @@ pub(crate) async fn resolve_or_pull_image(
422433
return Ok(None);
423434
}
424435

425-
if let Some(digest) = required_digest {
436+
if !required_files.is_empty() {
426437
if skipped.is_empty() {
427438
println!(
428-
" [..] no local guest image with {digest} found; downloading the latest cpu image"
439+
" [..] no local guest image with {} found; downloading the latest cpu image",
440+
required_files_label(required_files)
429441
);
430442
} else {
431443
println!(
432-
" [..] no local guest image with {digest} found (ignored {}); downloading the latest cpu image",
444+
" [..] no local guest image with {} found (ignored {}); downloading the latest cpu image",
445+
required_files_label(required_files),
433446
skipped.join(", ")
434447
);
435448
}
@@ -443,34 +456,69 @@ pub(crate) async fn resolve_or_pull_image(
443456
.join("metadata.json")
444457
.exists()
445458
{
459+
ensure_image_has_required_files(image_dir, &pulled, required_files)?;
446460
Ok(Some(pulled))
447461
} else {
448462
bail!("downloaded image {pulled}, but it is not available in {image_dir}")
449463
}
450464
}
451465

452-
fn retain_images_with_digest(
466+
fn retain_images_with_required_files(
453467
imgs: &mut Vec<String>,
454468
image_dir: &str,
455-
required_digest: Option<&str>,
469+
required_files: &[&str],
456470
) -> Vec<String> {
457-
let Some(required_digest) = required_digest else {
471+
if required_files.is_empty() {
458472
return Vec::new();
459-
};
473+
}
460474
let mut skipped = Vec::new();
461475
imgs.retain(|name| {
462-
let has_digest = Path::new(image_dir)
463-
.join(name)
464-
.join(required_digest)
465-
.is_file();
466-
if !has_digest {
476+
let has_required_files = image_has_required_files(image_dir, name, required_files);
477+
if !has_required_files {
467478
skipped.push(name.clone());
468479
}
469-
has_digest
480+
has_required_files
470481
});
471482
skipped
472483
}
473484

485+
fn image_has_required_files(image_dir: &str, image: &str, required_files: &[&str]) -> bool {
486+
required_files
487+
.iter()
488+
.all(|file| Path::new(image_dir).join(image).join(file).is_file())
489+
}
490+
491+
fn ensure_image_has_required_files(
492+
image_dir: &str,
493+
image: &str,
494+
required_files: &[&str],
495+
) -> Result<()> {
496+
let missing = missing_required_files(image_dir, image, required_files);
497+
if missing.is_empty() {
498+
return Ok(());
499+
}
500+
bail!(
501+
"image {image:?} under {image_dir} is missing required file(s): {}",
502+
missing.join(", ")
503+
)
504+
}
505+
506+
fn missing_required_files(image_dir: &str, image: &str, required_files: &[&str]) -> Vec<String> {
507+
required_files
508+
.iter()
509+
.filter(|file| !Path::new(image_dir).join(image).join(file).is_file())
510+
.map(|file| (*file).to_string())
511+
.collect()
512+
}
513+
514+
fn required_files_label(required_files: &[&str]) -> String {
515+
if required_files.is_empty() {
516+
"required file(s)".to_string()
517+
} else {
518+
required_files.join(", ")
519+
}
520+
}
521+
474522
fn pull_spec(name: &str) -> Option<PullSpec> {
475523
if !valid_image_name(name) {
476524
return None;

crates/dstackup/src/install.rs

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,12 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
9393
// empty, download the latest CPU image through the verified image path.
9494
// Pinning is validated before installing managed binaries, so an
9595
// incompatible image fails without leaving a half-built host install.
96-
let required_digest =
97-
(!o.no_kms && !o.allow_unpinned_image).then_some(os_image_digest_file(platform));
96+
let required_image_files = required_image_files(platform, !o.no_kms && !o.allow_unpinned_image);
9897
o.image = crate::image::resolve_or_pull_image(
9998
&images,
10099
o.image.as_deref(),
101100
!o.no_kms,
102-
required_digest,
101+
required_image_files,
103102
)
104103
.await?;
105104
let os_image_hash = resolve_image_pin(&o, &images, platform)?;
@@ -151,8 +150,10 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
151150
// The KMS's own image download-verify stays off for the single-node flow
152151
// (it would need a published image source), but we PIN the app OS image in
153152
// the webhook allowlist (resolved in preflight, fail-closed): digest.txt
154-
// holds the measured image hash the KMS reports for an app, so an app cannot
155-
// boot under a different, unmeasured image and still receive keys.
153+
// holds the unified measured image hash the KMS reports for an app (with
154+
// platform measurement material such as measurement.snp.cbor committed by
155+
// sha256sum.txt), so an app cannot boot under a different, unmeasured image
156+
// and still receive keys.
156157
// bootAuth/kms ignores osImages, so the KMS bootstrap itself is unaffected.
157158
let host_cfg = HostConfig {
158159
auth_webhook_url: format!("http://10.0.2.2:{}", o.auth_port),
@@ -832,15 +833,20 @@ fn split_addr_port(ep: &str) -> Result<(String, u16)> {
832833
))
833834
}
834835

835-
fn os_image_digest_file(platform: Platform) -> &'static str {
836+
fn os_image_digest_file(_platform: Platform) -> &'static str {
837+
"digest.txt"
838+
}
839+
840+
fn required_image_files(platform: Platform, require_pin: bool) -> &'static [&'static str] {
836841
match platform {
837-
Platform::AmdSevSnp => "digest.sev.txt",
838-
Platform::Tdx => "digest.txt",
842+
Platform::AmdSevSnp => &["digest.txt", "measurement.snp.cbor"],
843+
Platform::Tdx if require_pin => &["digest.txt"],
844+
Platform::Tdx => &[],
839845
}
840846
}
841847

842-
/// read the measured OS-image hash from the guest image's platform-specific
843-
/// digest file, used to pin which image apps may boot.
848+
/// read the measured OS-image hash from the guest image's unified digest file,
849+
/// used to pin which image apps may boot.
844850
/// Returns None when there's no image selected or no readable digest.
845851
fn resolve_os_image_hash(images: &str, image: Option<&str>, platform: Platform) -> Option<String> {
846852
let img = image?;
@@ -851,7 +857,7 @@ fn resolve_os_image_hash(images: &str, image: Option<&str>, platform: Platform)
851857
}
852858

853859
/// resolve the OS-image pin, failing CLOSED: in KMS mode a missing/empty
854-
/// platform digest is a hard error (an unpinned app could boot any unmeasured
860+
/// image digest is a hard error (an unpinned app could boot any unmeasured
855861
/// image and still get keys), unless the operator opts out with
856862
/// `--allow-unpinned-image`. Returns Some(hash) to pin, or None when pinning
857863
/// is deliberately off (`--no-kms`, or the explicit opt-out).

docs/hardware-enablement.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ cat /sys/module/kvm_amd/parameters/sev_snp
4949

5050
The AMDSEV verification path expects `dmesg` to show SEV-SNP and RMP initialization, and `sev_snp` to read `Y`.
5151

52-
Host enablement is necessary but not sufficient for onboarding with KMS. The selected guest image must also contain `digest.sev.txt`, which `dstackup install` uses to pin apps to the measured SNP OS image.
52+
Host enablement is necessary but not sufficient for onboarding with KMS. The selected guest image must also contain the unified `digest.txt` plus `measurement.snp.cbor`; `dstackup install` uses them to pin apps to the measured SNP OS image and ensure the SNP launch material is available.
5353

5454
## What dstackup checks
5555

docs/onboarding.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ sudo dstack deploy \
1212
curl http://127.0.0.1:8080/
1313
```
1414

15-
AMD SEV-SNP hosts use the same `dstackup` and `dstack` commands after you provide a guest image that contains the SNP image digest (`digest.sev.txt`). As of June 28, 2026, the latest stable CPU image from `meta-dstack` is TDX-pinned only, so the copy-paste path below is not the AMD happy path yet.
15+
AMD SEV-SNP hosts use the same `dstackup` and `dstack` commands after you provide a guest image that contains the unified image digest (`digest.txt`) and SNP measurement material (`measurement.snp.cbor`). If your image predates SNP measurement material, provide a newer or custom SNP-capable image before running `dstackup install`.
1616

1717
For multi-node production, Gateway TLS, custom domains, or on-chain governance, use the full [deployment guide](./deployment.md).
1818

@@ -93,7 +93,7 @@ sudo dstackup install --key-provider-src /path/to/key-provider-build
9393
sudo dstackup install --use-existing-key-provider 127.0.0.1:3443
9494
```
9595

96-
On AMD SEV-SNP, no SGX key provider is needed. The selected guest image must include `digest.sev.txt`; otherwise, `dstackup install` fails before it starts the host units because apps could not be pinned to the measured SNP OS image.
96+
On AMD SEV-SNP, no SGX key provider is needed. The selected guest image must include `digest.txt` and `measurement.snp.cbor`; otherwise, `dstackup install` fails before it starts the host units because apps could not be pinned to the measured SNP OS image or launched with the required SNP measurement material.
9797

9898
To use a GPU image, pull it before install:
9999

@@ -236,7 +236,7 @@ sudo /opt/dstack-test/bin/dstackup destroy --prefix /opt/dstack-test --purge
236236
This onboarding path is designed for one operator on one host.
237237

238238
- The VMM dashboard and management API bind to `127.0.0.1` by default. Use SSH tunneling for remote access.
239-
- `dstackup install` pins the app OS image hash from the selected guest image (`digest.txt` for TDX, `digest.sev.txt` for SEV-SNP). If the digest cannot be read, install fails unless you pass `--allow-unpinned-image`.
239+
- `dstackup install` pins the app OS image hash from the selected guest image (`digest.txt` on all platforms; SEV-SNP images must also carry `measurement.snp.cbor`). If pinning is enabled and the digest cannot be read, install fails; SEV-SNP also fails when the SNP measurement material is missing.
240240
- `dstack deploy` registers the app compose hash in the local auth allowlist from `dstackup install`. Without that allowlist update, a KMS-mode app can boot but will not receive keys.
241241
- Gateway is not part of this flow. Apps are exposed through direct host port mappings.
242242

@@ -256,15 +256,15 @@ If a release does not publish a SHA-256 digest, `dstackup image pull` and `dstac
256256

257257
If you use a custom prefix or image directory, pass the same `--prefix` or `--image-path` to `install` and `image` commands.
258258

259-
### Missing `digest.sev.txt` on AMD SEV-SNP
259+
### Missing SNP image material on AMD SEV-SNP
260260

261-
TDX images pin apps with `digest.txt`. AMD SEV-SNP images pin apps with `digest.sev.txt`. If the selected image does not contain `digest.sev.txt`, install fails with:
261+
All platforms pin apps with the unified `digest.txt`. AMD SEV-SNP images must also contain `measurement.snp.cbor`, which binds the SNP launch measurement material committed by `sha256sum.txt`. If the selected image does not contain the required files, install fails with an error such as:
262262

263263
```text
264-
no os-image pin: could not read digest.sev.txt
264+
image "dstack-..." under /var/lib/dstack/images is missing required file(s): measurement.snp.cbor
265265
```
266266

267-
Use `--image` or `--image-path` with an SNP-capable image that contains `digest.sev.txt`. Do not use `--allow-unpinned-image` for onboarding unless you intentionally want apps to boot without OS-image pinning.
267+
Use `--image` or `--image-path` with an SNP-capable image that contains `digest.txt` and `measurement.snp.cbor`. `--allow-unpinned-image` only bypasses host allowlist pinning; it does not make a TDX-only image SNP-capable.
268268

269269
### No key provider on TDX
270270

0 commit comments

Comments
 (0)