diff --git a/CHANGELOG.md b/CHANGELOG.md index 915483557..868b51e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - os: the Yocto guest-OS backend (`os/yocto/`) is deprecated in favor of mkosi (`os/mkosi/`), which is now the default and recommended backend. `os/build.sh` defaults to `--backend mkosi`, and `make os-image` / `make os-repro-check` build with mkosi. The Yocto builds move to `make os-image-yocto` / `make os-repro-check-yocto`; `make os-image-mkosi` / `make os-repro-check-mkosi` remain as aliases. Every Yocto entrypoint prints a deprecation warning, and the backend is kept only to rebuild existing Yocto images. The mkosi build still reads patches, units and scripts from `os/yocto/`, so the directory stays until those files move ### Removed +- vmm: pulling guest images from an OCI registry, together with the `[image] registry` setting, the `ListRegistryImages` and `PullRegistryImage` RPCs, the registry section of the Images panel, and `os/image/dstack-image-oci.sh`. Guest images are not published to a registry, so the feature had no users. A leftover `registry` line in `vmm.toml` is ignored; install images into the local image directory instead - verifier: the `debug` request field and the `acpi_tables` / `rtmr_debug` response fields. The per-event RTMR diff never had the events it diffed, so it reported every expected digest as missing. Requests that still send `debug` are accepted and the field is ignored. - sdk: `TlsKeyOptions.path` in the JavaScript SDK. `GetTlsKeyArgs` has no such field and `getTlsKey` never read it, so a caller who set it was silently ignored. Breaking at the type level only, and only for code whose value was already being discarded. `deriveKey`'s `path` is a real, deprecated Tappd-era parameter and stays; the Python, Rust and Go v0 TLS-key options never carried one - guest-agent: the `EmitEvent` RPC no longer records anything -- runtime RTMR3 events are system-owned in 0.6.0, so an app can no longer extend the measurement chain. The method itself stays on the unversioned path and always fails with an error naming the removal, rather than being deleted outright: a deleted method answers HTTP 404 `Service not found: EmitEvent`, which tells a 0.5.x caller nothing about why its events stopped being recorded, while the kept stub fails with a message naming the removal and pointing at `report_data`. **Breaking:** any app extending RTMR3 at runtime must stop; bind app data through `report_data` instead, which is what most callers wanted anyway diff --git a/docs/tutorials/guest-image-setup.md b/docs/tutorials/guest-image-setup.md index 98f4f617d..0f5595b79 100644 --- a/docs/tutorials/guest-image-setup.md +++ b/docs/tutorials/guest-image-setup.md @@ -241,59 +241,6 @@ The `image_path` should point to `/var/lib/dstack/images`. If VMM isn't finding the images, verify the path in the configuration matches where you installed them. -## OCI Registry Setup - -Guest images can be stored in any OCI-compatible container registry (Docker Hub, GHCR, Harbor, etc.), allowing VMM to discover and pull images directly from the web UI. - -### Pushing Images to a Registry - -Use the `dstack-image-oci.sh` script to package and push a guest image directory: - -```bash -# Push a standard image (auto-tags: version + sha256-hash) -./os/image/dstack-image-oci.sh push /var/lib/dstack/images/dstack-0.6.0 ghcr.io/your-org/guest-image - -# Current unified image is also used on NVIDIA hosts -./os/image/dstack-image-oci.sh push /var/lib/dstack/images/dstack-0.6.0 ghcr.io/your-org/guest-image --tag 0.6.0 - -# Push with a custom tag -./os/image/dstack-image-oci.sh push /var/lib/dstack/images/dstack-0.6.0 ghcr.io/your-org/guest-image --tag latest - -# List tags in the registry -./os/image/dstack-image-oci.sh list ghcr.io/your-org/guest-image -``` - -The script reads `metadata.json` and `digest.txt` from the image directory and auto-generates tags: - -| Image directory | Generated tags | -|---|---| -| `dstack-0.5.8` | `0.5.8`, `sha256-` | -| `dstack-dev-0.5.8` | `dev-0.5.8`, `sha256-` | -| `dstack-nvidia-0.5.8` | `nvidia-0.5.8`, `sha256-` | - -Prerequisites: `docker` CLI (for building), `python3`, registry login (`docker login`). - -### Configuring VMM to Use a Registry - -Add the `[image]` section to `vmm.toml`: - -```toml -[image] -# Local image directory (default: ~/.dstack-vmm/image) -# path = "/var/lib/dstack/images" - -# OCI registry for discovering and pulling images -registry = "ghcr.io/your-org/guest-image" -``` - -After restarting VMM, click **Images** in the web UI to browse the registry. Click **Pull** to download an image — it will be extracted to the local image directory automatically. - -### How It Works - -- **Push**: The script builds a `FROM scratch` Docker image containing the guest image files (kernel, initrd, rootfs, firmware, metadata) and pushes it to the registry. -- **Pull**: VMM fetches the OCI manifest via the Registry HTTP API v2, downloads each layer blob, and extracts the tar contents into the local image directory. No Docker daemon required on the VMM host. -- **Discovery**: VMM queries the registry's tag list API to show available versions alongside locally installed images. - ## Managing Multiple Image Versions You can have multiple image versions installed simultaneously: diff --git a/dstack/Cargo.lock b/dstack/Cargo.lock index 96b7a81ad..d290b562f 100644 --- a/dstack/Cargo.lock +++ b/dstack/Cargo.lock @@ -2413,7 +2413,6 @@ dependencies = [ "dstack-types", "dstack-vmm-rpc", "fatfs", - "flate2", "fs-err", "fscommon", "getrandom 0.3.4", @@ -2435,7 +2434,6 @@ dependencies = [ "path-absolutize", "ra-rpc", "rand 0.8.6", - "reqwest", "rocket", "rocket-vsock-listener", "safe-write", @@ -2449,7 +2447,6 @@ dependencies = [ "strip-ansi-escapes", "supervisor-client", "tailf", - "tar", "tempfile", "tokio", "tracing", diff --git a/dstack/vmm/Cargo.toml b/dstack/vmm/Cargo.toml index 0850c1697..c31b9f0df 100644 --- a/dstack/vmm/Cargo.toml +++ b/dstack/vmm/Cargo.toml @@ -61,10 +61,7 @@ fatfs.workspace = true fscommon.workspace = true or-panic.workspace = true url.workspace = true -reqwest.workspace = true rand.workspace = true -flate2.workspace = true -tar.workspace = true tempfile.workspace = true wait-timeout.workspace = true listenfd.workspace = true diff --git a/dstack/vmm/rpc/proto/vmm_rpc.proto b/dstack/vmm/rpc/proto/vmm_rpc.proto index 60eec34b9..49d1f72c7 100644 --- a/dstack/vmm/rpc/proto/vmm_rpc.proto +++ b/dstack/vmm/rpc/proto/vmm_rpc.proto @@ -437,10 +437,6 @@ service Vmm { // Remove a stopped supervisor process by ID. rpc SvRemove(Id) returns (google.protobuf.Empty); - // List images available in the configured OCI registry. - rpc ListRegistryImages(google.protobuf.Empty) returns (RegistryImageListResponse); - // Pull an image from the OCI registry to local storage. - rpc PullRegistryImage(PullRegistryImageRequest) returns (google.protobuf.Empty); // Delete a local guest image by name. rpc DeleteImage(Id) returns (google.protobuf.Empty); } @@ -450,29 +446,6 @@ message SvListResponse { repeated SvProcessInfo processes = 1; } -// Available images discovered from the OCI registry. -message RegistryImageListResponse { - repeated RegistryImageInfo images = 1; -} - -// Metadata for an image tag in the OCI registry. -message RegistryImageInfo { - // Tag name (e.g., "0.5.8", "nvidia-0.5.8") - string tag = 1; - // Whether this image is already downloaded locally - bool local = 2; - // Whether this image is currently being pulled - bool pulling = 3; - // Error message from the last failed pull attempt (empty if no error) - string error = 4; -} - -// Request to pull an image from the OCI registry. -message PullRegistryImageRequest { - // Tag to pull (e.g., "0.5.8") - string tag = 1; -} - // Information about a single supervisor process. message SvProcessInfo { string id = 1; diff --git a/dstack/vmm/src/app.rs b/dstack/vmm/src/app.rs index df3e99281..cb4f2a87d 100644 --- a/dstack/vmm/src/app.rs +++ b/dstack/vmm/src/app.rs @@ -59,7 +59,6 @@ mod image; mod mr_config; pub(crate) mod network; mod qemu; -pub(crate) mod registry; mod vm_info; mod workdir; @@ -302,12 +301,6 @@ pub struct GpuSpec { pub slot: String, } -#[derive(Clone, Debug)] -pub(crate) enum PullStatus { - Pulling, - Failed(String), -} - /// First delay before a removal asks netd again to release a VM's interfaces. #[cfg(not(test))] const RELEASE_RETRY_INITIAL: Duration = Duration::from_secs(2); @@ -321,8 +314,6 @@ pub struct App { pub config: Arc, pub supervisor: SupervisorClient, state: Arc>, - /// Pull status for registry images: tag → status. - pub(crate) pull_status: Arc>>, /// One lock per VM, held across a launch or a teardown. See /// [`App::launch_lock`]. launch_locks: Arc>>>>, @@ -356,7 +347,6 @@ impl App { removing: HashSet::new(), })), config: Arc::new(config), - pull_status: Arc::new(Mutex::new(std::collections::HashMap::new())), launch_locks: Arc::new(Mutex::new(HashMap::new())), } } diff --git a/dstack/vmm/src/app/registry.rs b/dstack/vmm/src/app/registry.rs deleted file mode 100644 index dbd763f61..000000000 --- a/dstack/vmm/src/app/registry.rs +++ /dev/null @@ -1,535 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -//! OCI Distribution API client for pulling dstack guest images directly from -//! a container registry without requiring a local Docker daemon. - -use std::{io::Read, path::Path}; - -use anyhow::{bail, Context, Result}; -use flate2::read::GzDecoder; -use reqwest::Client; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use tracing::info; - -fn build_client() -> Result { - Ok(Client::builder() - .timeout(std::time::Duration::from_secs(600)) - .build()?) -} - -// ─── Tag listing ──────────────────────────────────────────────────────────── - -/// List tags from a Docker Registry HTTP API v2 endpoint. -/// -/// `image_ref` is in the form `registry.example.com/repo/name`. -pub async fn list_registry_tags(image_ref: &str) -> Result> { - let (registry, repo) = parse_image_ref(image_ref)?; - let client = build_client()?; - - let url = format!("https://{registry}/v2/{repo}/tags/list"); - info!("fetching registry tags from {url}"); - - let response = client - .get(&url) - .send() - .await - .context("failed to fetch registry tags")?; - - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - return list_tags_with_token(&client, ®istry, &repo).await; - } - - if !response.status().is_success() { - bail!( - "registry returned HTTP {}: {}", - response.status(), - response.text().await.unwrap_or_default() - ); - } - - let tag_list: TagList = response - .json() - .await - .context("failed to parse registry tag list")?; - - Ok(tag_list.tags.unwrap_or_default()) -} - -/// Handle token-based auth (Docker Hub / registries requiring Bearer token). -async fn list_tags_with_token(client: &Client, registry: &str, repo: &str) -> Result> { - let token = fetch_token(client, registry, repo).await?; - let url = format!("https://{registry}/v2/{repo}/tags/list"); - let response = client - .get(&url) - .bearer_auth(&token) - .send() - .await - .context("failed to fetch registry tags with token")?; - - if !response.status().is_success() { - bail!( - "registry returned HTTP {} after auth: {}", - response.status(), - response.text().await.unwrap_or_default() - ); - } - - let tag_list: TagList = response - .json() - .await - .context("failed to parse registry tag list")?; - - Ok(tag_list.tags.unwrap_or_default()) -} - -// ─── Image pulling ────────────────────────────────────────────────────────── - -/// Pull an image from registry and extract to the local image directory. -/// -/// Fetches the OCI manifest, downloads each layer blob, and extracts -/// the tar (gzipped) contents into a flat directory. -pub async fn pull_and_extract(image_ref: &str, tag: &str, image_path: &Path) -> Result<()> { - validate_registry_tag(tag)?; - let (registry, repo) = parse_image_ref(image_ref)?; - let client = build_client()?; - - info!("pulling image {image_ref}:{tag}"); - - // Resolve authentication - let token = try_fetch_token(&client, ®istry, &repo).await; - - // Fetch manifest - let manifest = fetch_manifest(&client, ®istry, &repo, tag, token.as_deref()).await?; - - // Determine output directory - let output_dir = determine_output_dir(tag, image_path); - if output_dir.exists() { - bail!("image directory already exists: {}", output_dir.display()); - } - - // Extract into temp dir first, then rename atomically - let tmp_dir = image_path.join(format!(".tmp-pull-{tag}")); - if tmp_dir.exists() { - fs_err::remove_dir_all(&tmp_dir).context("failed to clean up stale temp dir")?; - } - fs_err::create_dir_all(&tmp_dir)?; - - let result = download_and_extract_layers( - &client, - ®istry, - &repo, - &manifest, - token.as_deref(), - &tmp_dir, - ) - .await; - - if let Err(e) = &result { - tracing::error!("pull failed, cleaning up temp dir: {e:#}"); - let _ = fs_err::remove_dir_all(&tmp_dir); - return result; - } - - // Verify metadata.json exists - if !tmp_dir.join("metadata.json").exists() { - let _ = fs_err::remove_dir_all(&tmp_dir); - bail!("pulled image does not contain metadata.json - not a valid dstack guest image"); - } - - // Move to final location - fs_err::rename(&tmp_dir, &output_dir).with_context(|| { - format!( - "failed to rename {} to {}", - tmp_dir.display(), - output_dir.display() - ) - })?; - - info!("image extracted to {}", output_dir.display()); - Ok(()) -} - -/// Fetch OCI image manifest. -fn fetch_manifest<'a>( - client: &'a Client, - registry: &'a str, - repo: &'a str, - tag: &'a str, - token: Option<&'a str>, -) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { fetch_manifest_inner(client, registry, repo, tag, token).await }) -} - -async fn fetch_manifest_inner( - client: &Client, - registry: &str, - repo: &str, - tag: &str, - token: Option<&str>, -) -> Result { - let url = format!("https://{registry}/v2/{repo}/manifests/{tag}"); - - let mut req = client.get(&url).header( - "Accept", - "application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json", - ); - if let Some(t) = token { - req = req.bearer_auth(t); - } - - let response = req.send().await.context("failed to fetch manifest")?; - - if !response.status().is_success() { - bail!( - "failed to fetch manifest: HTTP {} {}", - response.status(), - response.text().await.unwrap_or_default() - ); - } - - // Try to parse as a single manifest first - let body = response.text().await?; - if let Ok(manifest) = serde_json::from_str::(&body) { - if !manifest.layers.is_empty() { - return Ok(manifest); - } - } - - // Might be an index/manifest list — pick the first manifest - if let Ok(index) = serde_json::from_str::(&body) { - if let Some(first) = index.manifests.into_iter().find(|m| { - // Prefer the non-attestation manifest - !m.media_type - .as_deref() - .is_some_and(|mt| mt.contains("attestation")) - }) { - return fetch_manifest(client, registry, repo, &first.digest, token).await; - } - } - - bail!("unsupported manifest format"); -} - -/// Download and extract all layer blobs into `dest`. -async fn download_and_extract_layers( - client: &Client, - registry: &str, - repo: &str, - manifest: &OciManifest, - token: Option<&str>, - dest: &Path, -) -> Result<()> { - for (i, layer) in manifest.layers.iter().enumerate() { - let size_mb = layer.size as f64 / 1_048_576.0; - info!( - "downloading layer {}/{}: {} ({:.1} MB)", - i + 1, - manifest.layers.len(), - &layer.digest[..19.min(layer.digest.len())], - size_mb, - ); - - let url = format!("https://{registry}/v2/{repo}/blobs/{}", layer.digest); - let mut req = client.get(&url); - if let Some(t) = token { - req = req.bearer_auth(t); - } - - let response = req - .send() - .await - .with_context(|| format!("failed to download layer {}", layer.digest))?; - - if !response.status().is_success() { - bail!( - "failed to download blob {}: HTTP {}", - layer.digest, - response.status() - ); - } - - let bytes = response.bytes().await.context("failed to read blob body")?; - if bytes.len() as u64 != layer.size { - bail!( - "blob {} size mismatch: expected {}, received {}", - layer.digest, - layer.size, - bytes.len() - ); - } - let actual_digest = format!("sha256:{:x}", Sha256::digest(&bytes)); - if actual_digest != layer.digest { - bail!( - "blob digest mismatch: expected {}, received {}", - layer.digest, - actual_digest - ); - } - extract_layer(&bytes, &layer.media_type, dest)?; - } - - Ok(()) -} - -/// Extract a single layer (tar+gzip or tar) into `dest`. -fn extract_layer(data: &[u8], media_type: &str, dest: &Path) -> Result<()> { - let is_gzip = media_type.contains("gzip") - || media_type.contains("tar+gzip") - || (data.len() >= 2 && data[0] == 0x1f && data[1] == 0x8b); - - if is_gzip { - let decoder = GzDecoder::new(data); - let mut archive = tar::Archive::new(decoder); - unpack_archive(&mut archive, dest).context("failed to extract gzipped tar layer")?; - } else { - let mut archive = tar::Archive::new(data); - unpack_archive(&mut archive, dest).context("failed to extract tar layer")?; - } - - // Remove docker/OCI artifact directories that may appear in layers - for dir in &["dev", "etc", "proc", "sys"] { - let d = dest.join(dir); - if d.is_dir() { - let _ = fs_err::remove_dir(&d); - } - } - - Ok(()) -} - -fn unpack_archive(archive: &mut tar::Archive, dest: &Path) -> Result<()> { - for entry in archive.entries().context("failed to read tar entries")? { - let mut entry = entry.context("failed to read tar entry")?; - if !entry - .unpack_in(dest) - .context("failed to unpack tar entry")? - { - bail!("archive entry escapes destination"); - } - } - Ok(()) -} - -// ─── Token auth ───────────────────────────────────────────────────────────── - -/// Try to fetch a Bearer token. Returns None if the registry doesn't need one. -async fn try_fetch_token(client: &Client, registry: &str, repo: &str) -> Option { - // Probe the /v2/ endpoint to check if auth is needed - let probe = client - .get(format!("https://{registry}/v2/")) - .send() - .await - .ok()?; - - if probe.status() != reqwest::StatusCode::UNAUTHORIZED { - return None; - } - - // Parse WWW-Authenticate header for realm and service - let www_auth = probe - .headers() - .get("www-authenticate") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - let (realm, service) = parse_www_authenticate(www_auth); - - let token_url = if !realm.is_empty() { - format!("{realm}?service={service}&scope=repository:{repo}:pull") - } else { - format!("https://{registry}/v2/token?service={registry}&scope=repository:{repo}:pull") - }; - - let resp = client.get(&token_url).send().await.ok()?; - if !resp.status().is_success() { - return None; - } - - let token_data: TokenResponse = resp.json().await.ok()?; - Some(token_data.token) -} - -async fn fetch_token(client: &Client, registry: &str, repo: &str) -> Result { - try_fetch_token(client, registry, repo) - .await - .context("registry requires authentication but token exchange failed") -} - -/// Extract realm and service from a WWW-Authenticate: Bearer header. -fn parse_www_authenticate(header: &str) -> (String, String) { - let mut realm = String::new(); - let mut service = String::new(); - - for part in header.split(',') { - let part = part.trim(); - if let Some(v) = part - .strip_prefix("Bearer realm=\"") - .or_else(|| part.strip_prefix("realm=\"")) - { - realm = v.trim_end_matches('"').to_string(); - } else if let Some(v) = part.strip_prefix("service=\"") { - service = v.trim_end_matches('"').to_string(); - } - } - - (realm, service) -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -fn validate_registry_tag(tag: &str) -> Result<()> { - if tag.is_empty() - || tag == "." - || tag == ".." - || tag.contains('/') - || tag.contains('\\') - || !tag - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) - { - bail!("invalid registry tag"); - } - Ok(()) -} - -fn determine_output_dir(tag: &str, image_path: &Path) -> std::path::PathBuf { - let dir_name = if tag.starts_with("dstack-") { - tag.to_string() - } else { - format!("dstack-{tag}") - }; - image_path.join(dir_name) -} - -/// Parse "registry.example.com/repo/name" into ("registry.example.com", "repo/name"). -/// -/// For Docker Hub short names like "dstacktee/guest-image" (no dots in the -/// first component), automatically expands to "registry-1.docker.io/dstacktee/guest-image". -fn parse_image_ref(image_ref: &str) -> Result<(String, String)> { - let trimmed = image_ref - .trim_start_matches("https://") - .trim_start_matches("http://"); - - let first_slash = trimmed - .find('/') - .context("invalid image reference: no repository path")?; - - let first_component = &trimmed[..first_slash]; - let repo = &trimmed[first_slash + 1..]; - - if repo.is_empty() { - bail!("invalid image reference: empty repository"); - } - - // Docker Hub short names don't contain dots or colons - let registry = if first_component.contains('.') || first_component.contains(':') { - first_component.to_string() - } else { - // Docker Hub: "user/repo" → "registry-1.docker.io" - // and the repo needs "library/" prefix for official images - return Ok(( - "registry-1.docker.io".to_string(), - format!("{first_component}/{repo}"), - )); - }; - - Ok((registry, repo.to_string())) -} - -// ─── OCI types ────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct TagList { - tags: Option>, -} - -#[derive(Deserialize)] -struct TokenResponse { - token: String, -} - -#[derive(Deserialize, Debug)] -struct OciManifest { - #[serde(default)] - layers: Vec, -} - -#[derive(Deserialize, Debug)] -struct OciLayer { - #[serde(rename = "mediaType", default)] - media_type: String, - digest: String, - size: u64, -} - -#[derive(Deserialize, Debug)] -struct OciIndex { - manifests: Vec, -} - -#[derive(Deserialize, Debug)] -struct OciIndexEntry { - #[serde(rename = "mediaType")] - media_type: Option, - digest: String, -} - -// ─── Tests ────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registry_tag_is_confined_to_one_image_store_entry() { - for valid in ["v0.6.0", "dstack-fixture_1", "sha256-deadbeef"] { - validate_registry_tag(valid).unwrap(); - } - for invalid in [ - "", - ".", - "..", - "../escape", - "dstack-../../escape", - "nested/tag", - r"nested\tag", - "tag,option", - "tag=value", - ] { - assert!(validate_registry_tag(invalid).is_err(), "{invalid}"); - } - } - - #[test] - fn test_parse_image_ref_private_registry() { - let (reg, repo) = parse_image_ref("cr.kvin.wang/dstack/guest-image").unwrap(); - assert_eq!(reg, "cr.kvin.wang"); - assert_eq!(repo, "dstack/guest-image"); - } - - #[test] - fn test_parse_image_ref_docker_hub() { - let (reg, repo) = parse_image_ref("dstacktee/guest-image").unwrap(); - assert_eq!(reg, "registry-1.docker.io"); - assert_eq!(repo, "dstacktee/guest-image"); - } - - #[test] - fn test_parse_image_ref_with_scheme() { - let (reg, repo) = parse_image_ref("https://ghcr.io/dstack-tee/guest-image").unwrap(); - assert_eq!(reg, "ghcr.io"); - assert_eq!(repo, "dstack-tee/guest-image"); - } - - #[test] - fn test_parse_www_authenticate() { - let (realm, service) = parse_www_authenticate( - r#"Bearer realm="https://auth.docker.io/token",service="registry.docker.io""#, - ); - assert_eq!(realm, "https://auth.docker.io/token"); - assert_eq!(service, "registry.docker.io"); - } -} diff --git a/dstack/vmm/src/config.rs b/dstack/vmm/src/config.rs index 0d3d40a3e..5aa8846f0 100644 --- a/dstack/vmm/src/config.rs +++ b/dstack/vmm/src/config.rs @@ -632,9 +632,6 @@ pub struct ImageConfig { /// Path to guest image directory #[serde(default)] pub path: PathBuf, - /// OCI image registry for guest images (e.g., "dstacktee/guest-image") - #[serde(default)] - pub registry: String, } #[derive(Debug, Clone, Deserialize)] diff --git a/dstack/vmm/src/main_service.rs b/dstack/vmm/src/main_service.rs index ce29fc748..5fb9742d5 100644 --- a/dstack/vmm/src/main_service.rs +++ b/dstack/vmm/src/main_service.rs @@ -14,12 +14,10 @@ use dstack_vmm_rpc::vmm_server::{VmmRpc, VmmServer}; use dstack_vmm_rpc::{ AppId, ComposeHash as RpcComposeHash, GatewaySettings, GetInfoResponse, GetMetaResponse, Id, ImageInfo as RpcImageInfo, ImageListResponse, KmsSettings, ListGpusResponse, PublicKeyResponse, - PullRegistryImageRequest, RegistryImageInfo, RegistryImageListResponse, ReloadVmsResponse, - ResizeVmRequest, ResourcesSettings, StatusRequest, StatusResponse, SvListResponse, - SvProcessInfo, UpdateVmRequest, VersionResponse, VmConfiguration, + ReloadVmsResponse, ResizeVmRequest, ResourcesSettings, StatusRequest, StatusResponse, + SvListResponse, SvProcessInfo, UpdateVmRequest, VersionResponse, VmConfiguration, }; use fs_err as fs; -use or_panic::ResultOrPanic; use path_absolutize::Absolutize; use ra_rpc::{CallContext, RpcCall}; use tracing::{info, warn}; @@ -1400,51 +1398,6 @@ impl VmmRpc for RpcHandler { Ok(()) } - async fn list_registry_images(self) -> Result { - let registry = &self.app.config.image.registry; - if registry.is_empty() { - return Ok(RegistryImageListResponse { images: vec![] }); - } - - let tags = crate::app::registry::list_registry_tags(registry) - .await - .context("failed to list registry tags")?; - - // Get local images to mark which are already downloaded - let local_images = self.app.list_images()?; - let local_names: std::collections::HashSet = - local_images.into_iter().map(|(name, _)| name).collect(); - - let pull_status = self.app.pull_status.lock().or_panic("mutex poisoned"); - - // Filter to version-like tags (skip sha256-* hash tags) - let images = tags - .into_iter() - .filter(|tag| !tag.starts_with("sha256-")) - .map(|tag| { - let local_name = if tag.starts_with("dstack-") { - tag.clone() - } else { - format!("dstack-{tag}") - }; - let is_local = local_names.contains(&local_name); - let (is_pulling, error) = match pull_status.get(&tag) { - Some(crate::app::PullStatus::Pulling) => (true, String::new()), - Some(crate::app::PullStatus::Failed(msg)) => (false, msg.clone()), - None => (false, String::new()), - }; - RegistryImageInfo { - tag, - local: is_local, - pulling: is_pulling, - error, - } - }) - .collect(); - - Ok(RegistryImageListResponse { images }) - } - async fn delete_image(self, request: Id) -> Result<()> { let name = &request.id; if name.is_empty() || name.contains("..") || name.contains('/') { @@ -1477,51 +1430,6 @@ impl VmmRpc for RpcHandler { info!("deleted local image: {name}"); Ok(()) } - - async fn pull_registry_image(self, request: PullRegistryImageRequest) -> Result<()> { - let registry = &self.app.config.image.registry; - if registry.is_empty() { - bail!("image registry is not configured"); - } - - // Check if already pulling - { - let mut status = self.app.pull_status.lock().or_panic("mutex poisoned"); - if matches!( - status.get(&request.tag), - Some(crate::app::PullStatus::Pulling) - ) { - bail!("image {} is already being pulled", request.tag); - } - status.insert(request.tag.clone(), crate::app::PullStatus::Pulling); - } - - // Spawn background task - let tag = request.tag.clone(); - let registry = registry.clone(); - let image_path = self.app.config.image.path.clone(); - let pull_status = self.app.pull_status.clone(); - - info!("starting background pull for {tag}"); - tokio::spawn(async move { - let result = crate::app::registry::pull_and_extract(®istry, &tag, &image_path).await; - - let mut status = pull_status.lock().unwrap_or_else(|e| e.into_inner()); - match result { - Ok(()) => { - status.remove(&tag); - info!("registry image {tag} pulled successfully"); - } - Err(e) => { - let msg = format!("{e:#}"); - tracing::error!("failed to pull registry image {tag}: {msg}"); - status.insert(tag, crate::app::PullStatus::Failed(msg)); - } - } - }); - - Ok(()) - } } impl RpcCall for RpcHandler { diff --git a/dstack/vmm/ui/src/composables/useVmManager.ts b/dstack/vmm/ui/src/composables/useVmManager.ts index 9d01f5d58..8f9e1fb8f 100644 --- a/dstack/vmm/ui/src/composables/useVmManager.ts +++ b/dstack/vmm/ui/src/composables/useVmManager.ts @@ -1767,82 +1767,24 @@ type CreateVmPayloadSource = { return features.length > 0 ? features.join(', ') : 'None'; } - // ── Image Registry ───────────────────────────────────────────── - const showImageRegistry = ref(false); - const registryImages = ref([] as Array<{ tag: string; local: boolean; pulling: boolean; error: string }>); - const registryLoading = ref(false); - let registryRefreshTimer: ReturnType | null = null; - - async function loadRegistryImages() { - const isInitialLoad = registryImages.value.length === 0; - if (isInitialLoad) { - registryLoading.value = true; - } - try { - const data = await vmmRpc.listRegistryImages({}); - registryImages.value = (data.images || []).sort((a: any, b: any) => { - // Sort by tag descending (newest versions first) - return (b.tag || '').localeCompare(a.tag || '', undefined, { numeric: true }); - }); - // If any image is pulling, refresh local images too - if (registryImages.value.some((img: any) => img.pulling)) { - loadImages(); - } - } catch (error) { - recordError('failed to load registry images', error); - } finally { - registryLoading.value = false; - } - } - - async function pullRegistryImage(tag: string) { - // Optimistic update: mark as pulling immediately, clear previous error - const img = registryImages.value.find((i: any) => i.tag === tag); - if (img) { - img.pulling = true; - img.error = ''; - } - try { - await vmmRpc.pullRegistryImage({ tag }); - } catch (error) { - // Revert optimistic update on failure - if (img) { - img.pulling = false; - } - recordError(`failed to pull image ${tag}`, error); - } - } + // ── Images ───────────────────────────────────────────────────── + const showImages = ref(false); async function deleteImage(name: string) { if (!confirm(`Delete local image "${name}"?`)) return; try { await vmmRpc.deleteImage({ id: name }); await loadImages(); - await loadRegistryImages(); } catch (error) { recordError(`failed to delete image ${name}`, error); } } - async function openImageRegistry() { - showImageRegistry.value = true; - await Promise.all([loadImages(), loadRegistryImages()]); - registryRefreshTimer = setInterval(async () => { - await loadRegistryImages(); - // Refresh local images if something just finished pulling - if (registryImages.value.some((img: any) => img.pulling)) { - await loadImages(); - } - }, 3000); + async function openImages() { + showImages.value = true; + await loadImages(); } - watch(showImageRegistry, (open) => { - if (!open && registryRefreshTimer) { - clearInterval(registryRefreshTimer); - registryRefreshTimer = null; - } - }); - // ── Process Manager ───────────────────────────────────────────── const showProcessManager = ref(false); const supervisorProcesses = ref([] as any[]); @@ -2013,13 +1955,9 @@ type CreateVmPayloadSource = { svStatusClass, svIsRunning, svIsStopped, - showImageRegistry, - registryImages, - registryLoading, - loadRegistryImages, - pullRegistryImage, + showImages, deleteImage, - openImageRegistry, + openImages, }; } diff --git a/dstack/vmm/ui/src/templates/app.html b/dstack/vmm/ui/src/templates/app.html index bb24dabe0..ba7348b3a 100644 --- a/dstack/vmm/ui/src/templates/app.html +++ b/dstack/vmm/ui/src/templates/app.html @@ -35,7 +35,7 @@

dstack-vmm

Reload VMs - - + +
- -

Local

-
No local images found.
- +
No local images found.
+
@@ -522,60 +520,6 @@

Registry

-
Loading registry tags...
-
- No registry configured. Set [image] registry in vmm.toml. -
-
Name
- - - - - - - - - - - - - - - - - -
TagStatusActions
{{ img.tag }} - - - Pulling... - - - - Failed - - - - Local - - - - Remote - - - - Downloading - Downloaded -
- ⚠ {{ img.error }} -
diff --git a/dstack/vmm/vmm.toml b/dstack/vmm/vmm.toml index 1cf52e0c0..64f959e73 100644 --- a/dstack/vmm/vmm.toml +++ b/dstack/vmm/vmm.toml @@ -17,8 +17,6 @@ node_name = "" [image] # Path to guest image directory (default: ~/.dstack-vmm/image) # path = "" -# OCI image registry for guest images (e.g., "dstacktee/guest-image") -registry = "" [cvm] # TEE platform: "auto", "tdx", or "amd-sev-snp". Auto selects AMD SEV-SNP when host CPU flags include sev_snp, otherwise TDX. diff --git a/os/image/README.md b/os/image/README.md index d1f4e1256..2aa50abab 100644 --- a/os/image/README.md +++ b/os/image/README.md @@ -43,10 +43,6 @@ match, UKI assembly fails. Because this is release-format post-processing rather than a Yocto operation, the helper lives beside the common assembler. -`dstack-image-oci.sh` pushes and lists assembled guest-image directories in an -OCI registry. It is likewise independent of the backend that produced the -image. - ## Kernel build tree The optional `artifacts.kernel_devel` manifest entry is published as diff --git a/os/image/dstack-image-oci.sh b/os/image/dstack-image-oci.sh deleted file mode 100755 index 580d6e334..000000000 --- a/os/image/dstack-image-oci.sh +++ /dev/null @@ -1,309 +0,0 @@ -#!/bin/bash -# SPDX-FileCopyrightText: © 2025 Phala Network -# SPDX-License-Identifier: Apache-2.0 -# -# dstack guest image OCI packaging tool -# Pack and push dstack guest OS images to an OCI-compatible container registry. -set -euo pipefail - -usage() { - local status=${1:-1} - cat < [options] - -Commands: - push [--tag ] Pack and push image to registry - list [--filter ] List available tags in registry - -Arguments: - Path to a dstack guest image directory (contains metadata.json) - Full image reference (e.g., ghcr.io/org/guest-image) - -Examples: - $0 push ./dstack-0.6.0 ghcr.io/dstack-tee/guest-image - $0 push ./dstack-0.6.0 ghcr.io/dstack-tee/guest-image --tag 0.6.0 - $0 list ghcr.io/dstack-tee/guest-image - $0 list ghcr.io/dstack-tee/guest-image --filter nvidia -EOF - exit "$status" -} - -COMMAND="${1:-}" -[ -z "$COMMAND" ] && usage -shift - -# --- PUSH --- -cmd_push() ( - local image_dir="" - local image_ref="" - local extra_tag="" - - while [ $# -gt 0 ]; do - case "$1" in - --tag) - [ $# -ge 2 ] || { echo "Error: --tag requires a value"; exit 1; } - extra_tag="$2" - shift 2 - ;; - -h|--help) usage 0 ;; - -*) echo "Unknown option: $1"; exit 1 ;; - *) - if [ -z "$image_dir" ]; then - image_dir="$1" - elif [ -z "$image_ref" ]; then - image_ref="$1" - else - echo "Unexpected argument: $1"; exit 1 - fi - shift - ;; - esac - done - - [ -z "$image_dir" ] && { echo "Error: image directory required"; usage; } - [ -z "$image_ref" ] && { echo "Error: image reference required"; usage; } - [ -d "$image_dir" ] || { echo "Error: $image_dir is not a directory"; exit 1; } - - local metadata="$image_dir/metadata.json" - [ -f "$metadata" ] || { echo "Error: metadata.json not found in $image_dir"; exit 1; } - - # Read image info - local version - version=$(python3 - "$metadata" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as file: - print(json.load(file)["version"]) -PY - ) - if [[ ! "$version" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then - echo "Error: metadata.json contains an invalid version: $version" - exit 1 - fi - local digest_file="$image_dir/digest.txt" - local os_image_hash="" - if [ -f "$digest_file" ]; then - os_image_hash=$(tr -d '\n\r' < "$digest_file") - if [[ ! "$os_image_hash" =~ ^[0-9A-Fa-f]{64}$ ]]; then - echo "Error: digest.txt must contain one SHA-256 hex digest" - exit 1 - fi - os_image_hash=${os_image_hash,,} - fi - - # Detect image variant from directory name - local dirname - dirname=$(basename "$image_dir") - local variant="" - if [[ "$dirname" == *-nvidia-dev-* ]]; then - variant="nvidia-dev" - elif [[ "$dirname" == *-nvidia-* ]]; then - variant="nvidia" - elif [[ "$dirname" == *-dev-* ]]; then - variant="dev" - elif [[ "$dirname" == *-cloud-* ]]; then - variant="cloud" - fi - - # Build tag list - local tags=() - if [ -n "$extra_tag" ]; then - tags+=("$extra_tag") - else - # Auto-generate tags from variant + version - if [ -n "$variant" ]; then - tags+=("${variant}-${version}") - else - tags+=("${version}") - fi - if [ -n "$os_image_hash" ]; then - tags+=("sha256-${os_image_hash}") - fi - fi - - echo "=== Packing dstack guest image ===" - echo " Source: $image_dir" - echo " Version: $version" - echo " Variant: ${variant:-standard}" - echo " Hash: ${os_image_hash:-}" - echo " Registry: $image_ref" - echo " Tags: ${tags[*]}" - echo "" - - # Create build context in a temp directory - local tmp_dir - tmp_dir=$(mktemp -d) - trap 'rm -rf "$tmp_dir"' EXIT - - # Collect all files - local files=() - for f in "$image_dir"/*; do - if [ -f "$f" ]; then - local name - name=$(basename "$f") - if [[ ! "$name" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then - echo "Error: image filename is not OCI-packaging safe: $name" - exit 1 - fi - files+=("$name") - fi - done - - # Generate Dockerfile - { - echo "FROM scratch" - for f in "${files[@]}"; do - echo "COPY $f /" - done - echo "LABEL org.opencontainers.image.title=\"dstack-guest-image\"" - echo "LABEL org.opencontainers.image.version=\"$version\"" - echo "LABEL wang.dstack.os-image-hash=\"${os_image_hash}\"" - echo "LABEL wang.dstack.variant=\"${variant:-standard}\"" - } > "$tmp_dir/Dockerfile" - - # Copy files to build context - for f in "${files[@]}"; do - cp "$image_dir/$f" "$tmp_dir/" - done - - # Build - local primary_ref="${image_ref}:${tags[0]}" - echo "Building: $primary_ref" - docker build -t "$primary_ref" "$tmp_dir" - - # Tag additional tags - for ((i=1; i<${#tags[@]}; i++)); do - local ref="${image_ref}:${tags[$i]}" - echo "Tagging: $ref" - docker tag "$primary_ref" "$ref" - done - - # Push all tags - for tag in "${tags[@]}"; do - local ref="${image_ref}:${tag}" - echo "Pushing: $ref" - docker push "$ref" - done - - # Build and push measurement-only image (no rootfs, for verifier) - if [ -n "$os_image_hash" ]; then - local mr_tag="mr-sha256-${os_image_hash}" - local mr_dir - mr_dir="$tmp_dir/measurement" - mkdir -p "$mr_dir" - - # Read rootfs filename from metadata to exclude it - local rootfs_name - rootfs_name=$(python3 - "$metadata" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as file: - print(json.load(file).get("rootfs", "")) -PY - ) - - # Collect files excluding rootfs - local mr_files=() - for f in "${files[@]}"; do - if [ "$f" != "$rootfs_name" ]; then - mr_files+=("$f") - cp "$image_dir/$f" "$mr_dir/" - fi - done - - { - echo "FROM scratch" - for f in "${mr_files[@]}"; do - echo "COPY $f /" - done - echo "LABEL org.opencontainers.image.title=\"dstack-guest-image-mr\"" - echo "LABEL org.opencontainers.image.version=\"$version\"" - echo "LABEL wang.dstack.os-image-hash=\"${os_image_hash}\"" - echo "LABEL wang.dstack.variant=\"${variant:-standard}\"" - echo "LABEL wang.dstack.measurement-only=\"true\"" - } > "$mr_dir/Dockerfile" - - local mr_ref="${image_ref}:${mr_tag}" - echo "" - echo "Building measurement image (no rootfs): $mr_ref" - echo " Files: ${mr_files[*]}" - docker build -t "$mr_ref" "$mr_dir" - - echo "Pushing: $mr_ref" - docker push "$mr_ref" - - tags+=("$mr_tag") - fi - - rm -rf "$tmp_dir" - trap - EXIT - - echo "" - echo "=== Done ===" - for tag in "${tags[@]}"; do - echo " ${image_ref}:${tag}" - done -) - -# --- LIST --- -cmd_list() { - local image_ref="" - local filter="" - - while [ $# -gt 0 ]; do - case "$1" in - --filter) - [ $# -ge 2 ] || { echo "Error: --filter requires a value"; exit 1; } - filter="$2" - shift 2 - ;; - -h|--help) usage 0 ;; - -*) echo "Unknown option: $1"; exit 1 ;; - *) - if [ -z "$image_ref" ]; then - image_ref="$1" - else - echo "Unexpected argument: $1"; exit 1 - fi - shift - ;; - esac - done - - [ -z "$image_ref" ] && { echo "Error: image reference required"; usage; } - - echo "=== Tags for ${image_ref} ===" - - # Parse registry and repo from image_ref - local registry repo - registry="${image_ref%%/*}" - repo="${image_ref#*/}" - - local tags_json - tags_json=$(skopeo list-tags "docker://${image_ref}" 2>/dev/null || \ - curl -sf "https://${registry}/v2/${repo}/tags/list" 2>/dev/null || \ - echo '{"tags":[]}') - - python3 -c ' -import json -import re -import sys - -data = json.load(sys.stdin) -tags = sorted(data.get("Tags", data.get("tags", []))) -filt = sys.argv[1] -for tag in tags: - if not filt or re.search(filt, tag): - print(f" {tag}") -' "$filter" <<< "$tags_json" -} - -# Dispatch -case "$COMMAND" in - push) cmd_push "$@" ;; - list) cmd_list "$@" ;; - -h|--help) usage 0 ;; - *) echo "Unknown command: $COMMAND"; usage ;; -esac