Skip to content

Commit db46132

Browse files
authored
Merge pull request #764 from Dstack-TEE/codex/remove-digest-sev-txt
dstackup: drop digest.sev.txt image pin
2 parents c9a18db + 352736b commit db46132

6 files changed

Lines changed: 99 additions & 46 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 `digest.txt`.
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: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
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
9+
//! `install` validates the selected image against `digest.txt`, the OS image
10+
//! hash used on all platforms. HTTP + checksum are native (reqwest is
1211
//! already linked via the prpc client; sha2 verifies inline); only `tar` is
1312
//! shelled out, since GNU tar is ubiquitous and battle-tested on archive edges.
1413
@@ -364,7 +363,7 @@ pub(crate) async fn resolve_or_pull_image(
364363
image_dir: &str,
365364
requested: Option<&str>,
366365
require: bool,
367-
required_digest: Option<&str>,
366+
required_files: &[&str],
368367
) -> Result<Option<String>> {
369368
if let Some(name) = requested {
370369
if !valid_image_name(name) {
@@ -375,23 +374,29 @@ pub(crate) async fn resolve_or_pull_image(
375374
.join("metadata.json")
376375
.exists()
377376
{
377+
ensure_image_has_required_files(image_dir, name, required_files)?;
378378
return Ok(Some(name.to_string()));
379379
}
380380
if let Some(spec) = pull_spec(name) {
381381
println!(" [..] image {name} not found locally; downloading it");
382382
let pulled = pull(Some(&spec.version), spec.gpu, image_dir, false, false).await?;
383+
ensure_image_has_required_files(image_dir, &pulled, required_files)?;
383384
return Ok(Some(pulled));
384385
}
385-
return resolve_image(image_dir, Some(name), require);
386+
let resolved = resolve_image(image_dir, Some(name), require)?;
387+
if let Some(resolved) = &resolved {
388+
ensure_image_has_required_files(image_dir, resolved, required_files)?;
389+
}
390+
return Ok(resolved);
386391
}
387392

388393
let mut imgs = installed_images(image_dir);
389-
let skipped = retain_images_with_digest(&mut imgs, image_dir, required_digest);
394+
let skipped = retain_images_with_required_files(&mut imgs, image_dir, required_files);
390395
if let Some(newest) = imgs.pop() {
391396
if !skipped.is_empty() {
392397
println!(
393398
" [!] ignoring image(s) without {}: {}",
394-
required_digest.unwrap_or("required digest"),
399+
required_files_label(required_files),
395400
skipped.join(", ")
396401
);
397402
}
@@ -407,12 +412,16 @@ pub(crate) async fn resolve_or_pull_image(
407412
}
408413

409414
if !require {
410-
if let Some(digest) = required_digest {
415+
if !required_files.is_empty() {
411416
if skipped.is_empty() {
412-
println!(" [!] no guest image in {image_dir} with {digest} - `dstack deploy -c <compose>` will need one (`dstackup image pull`)");
417+
println!(
418+
" [!] no guest image in {image_dir} with {} - `dstack deploy -c <compose>` will need one (`dstackup image pull`)",
419+
required_files_label(required_files)
420+
);
413421
} else {
414422
println!(
415-
" [!] no guest image in {image_dir} with {digest}; ignored {} - `dstack deploy -c <compose>` will need one (`dstackup image pull`)",
423+
" [!] no guest image in {image_dir} with {}; ignored {} - `dstack deploy -c <compose>` will need one (`dstackup image pull`)",
424+
required_files_label(required_files),
416425
skipped.join(", ")
417426
);
418427
}
@@ -422,14 +431,16 @@ pub(crate) async fn resolve_or_pull_image(
422431
return Ok(None);
423432
}
424433

425-
if let Some(digest) = required_digest {
434+
if !required_files.is_empty() {
426435
if skipped.is_empty() {
427436
println!(
428-
" [..] no local guest image with {digest} found; downloading the latest cpu image"
437+
" [..] no local guest image with {} found; downloading the latest cpu image",
438+
required_files_label(required_files)
429439
);
430440
} else {
431441
println!(
432-
" [..] no local guest image with {digest} found (ignored {}); downloading the latest cpu image",
442+
" [..] no local guest image with {} found (ignored {}); downloading the latest cpu image",
443+
required_files_label(required_files),
433444
skipped.join(", ")
434445
);
435446
}
@@ -443,34 +454,69 @@ pub(crate) async fn resolve_or_pull_image(
443454
.join("metadata.json")
444455
.exists()
445456
{
457+
ensure_image_has_required_files(image_dir, &pulled, required_files)?;
446458
Ok(Some(pulled))
447459
} else {
448460
bail!("downloaded image {pulled}, but it is not available in {image_dir}")
449461
}
450462
}
451463

452-
fn retain_images_with_digest(
464+
fn retain_images_with_required_files(
453465
imgs: &mut Vec<String>,
454466
image_dir: &str,
455-
required_digest: Option<&str>,
467+
required_files: &[&str],
456468
) -> Vec<String> {
457-
let Some(required_digest) = required_digest else {
469+
if required_files.is_empty() {
458470
return Vec::new();
459-
};
471+
}
460472
let mut skipped = Vec::new();
461473
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 {
474+
let has_required_files = image_has_required_files(image_dir, name, required_files);
475+
if !has_required_files {
467476
skipped.push(name.clone());
468477
}
469-
has_digest
478+
has_required_files
470479
});
471480
skipped
472481
}
473482

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

crates/dstackup/src/install.rs

Lines changed: 18 additions & 12 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(!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 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-
match platform {
837-
Platform::AmdSevSnp => "digest.sev.txt",
838-
Platform::Tdx => "digest.txt",
836+
fn os_image_digest_file(_platform: Platform) -> &'static str {
837+
"digest.txt"
838+
}
839+
840+
fn required_image_files(require_pin: bool) -> &'static [&'static str] {
841+
if require_pin {
842+
&["digest.txt"]
843+
} else {
844+
&[]
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 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 `digest.txt`, which `dstackup install` uses to pin apps to the measured OS image.
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 image digest (`digest.txt`).
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`; otherwise, `dstackup install` fails before it starts the host units because apps could not be pinned to the measured OS image.
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). If pinning is enabled and the digest cannot be read, install fails.
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 `digest.txt`
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 `digest.txt`. If the selected image does not contain `digest.txt`, install fails with:
262262

263263
```text
264-
no os-image pin: could not read digest.sev.txt
264+
no os-image pin: could not read digest.txt
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 image that contains `digest.txt`. Do not use `--allow-unpinned-image` for onboarding unless you intentionally want apps to boot without OS-image pinning.
268268

269269
### No key provider on TDX
270270

0 commit comments

Comments
 (0)