Skip to content

Commit 903a7ee

Browse files
committed
feat(vmm): add OCI registry image discovery and pull support
Add ability for VMM to discover available guest images from an OCI registry and pull them on-demand through the web UI. Images are pulled in the background with status tracked server-side, surviving page refreshes. The UI auto-refreshes every 3s while the registry panel is open. - New `image_registry` config field (e.g., "cr.kvin.wang/dstack/guest-image") - New RPC: ListRegistryImages, PullRegistryImage - Registry module: list tags via Docker Registry HTTP API v2, pull and extract via `docker export` - Background pull with pulling state in App memory - UI: Image Registry button + dialog with pull/status per tag
1 parent d46ba28 commit 903a7ee

10 files changed

Lines changed: 521 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vmm/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ fatfs.workspace = true
5555
fscommon.workspace = true
5656
or-panic.workspace = true
5757
url.workspace = true
58+
reqwest.workspace = true
5859

5960
[dev-dependencies]
6061
insta.workspace = true

vmm/rpc/proto/vmm_rpc.proto

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,11 @@ service Vmm {
350350
rpc SvStop(Id) returns (google.protobuf.Empty);
351351
// Remove a stopped supervisor process by ID.
352352
rpc SvRemove(Id) returns (google.protobuf.Empty);
353+
354+
// List images available in the configured OCI registry.
355+
rpc ListRegistryImages(google.protobuf.Empty) returns (RegistryImageListResponse);
356+
// Pull an image from the OCI registry to local storage.
357+
rpc PullRegistryImage(PullRegistryImageRequest) returns (google.protobuf.Empty);
353358
}
354359

355360
// DHCP lease event reported by the host DHCP server.
@@ -365,6 +370,27 @@ message SvListResponse {
365370
repeated SvProcessInfo processes = 1;
366371
}
367372

373+
// Available images discovered from the OCI registry.
374+
message RegistryImageListResponse {
375+
repeated RegistryImageInfo images = 1;
376+
}
377+
378+
// Metadata for an image tag in the OCI registry.
379+
message RegistryImageInfo {
380+
// Tag name (e.g., "0.5.8", "nvidia-0.5.8")
381+
string tag = 1;
382+
// Whether this image is already downloaded locally
383+
bool local = 2;
384+
// Whether this image is currently being pulled
385+
bool pulling = 3;
386+
}
387+
388+
// Request to pull an image from the OCI registry.
389+
message PullRegistryImageRequest {
390+
// Tag to pull (e.g., "0.5.8")
391+
string tag = 1;
392+
}
393+
368394
// Information about a single supervisor process.
369395
message SvProcessInfo {
370396
string id = 1;

vmm/src/app.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pub use qemu::{VmConfig, VmWorkDir};
3535
mod id_pool;
3636
mod image;
3737
mod qemu;
38+
pub(crate) mod registry;
3839

3940
#[derive(Deserialize, Serialize, Debug, Clone)]
4041
pub struct PortMapping {
@@ -124,6 +125,8 @@ pub struct App {
124125
pub supervisor: SupervisorClient,
125126
state: Arc<Mutex<AppState>>,
126127
forward_service: Arc<tokio::sync::Mutex<ForwardService>>,
128+
/// Tags currently being pulled from the image registry.
129+
pub(crate) pulling_tags: Arc<Mutex<std::collections::HashSet<String>>>,
127130
}
128131

129132
impl App {
@@ -152,6 +155,7 @@ impl App {
152155
})),
153156
config: Arc::new(config),
154157
forward_service: Arc::new(tokio::sync::Mutex::new(ForwardService::new())),
158+
pulling_tags: Arc::new(Mutex::new(std::collections::HashSet::new())),
155159
}
156160
}
157161

vmm/src/app/registry.rs

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// SPDX-FileCopyrightText: © 2025 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
use std::path::Path;
6+
use std::process::Stdio;
7+
8+
use anyhow::{bail, Context, Result};
9+
use serde::Deserialize;
10+
use tokio::process::Command;
11+
use tracing::info;
12+
13+
/// List tags from a Docker Registry HTTP API v2 endpoint.
14+
///
15+
/// `image_ref` is in the form `registry.example.com/repo/name`.
16+
pub async fn list_registry_tags(image_ref: &str) -> Result<Vec<String>> {
17+
let (registry, repo) = parse_image_ref(image_ref)?;
18+
19+
// Try Docker Registry HTTP API v2
20+
let url = format!("https://{registry}/v2/{repo}/tags/list");
21+
info!("fetching registry tags from {url}");
22+
23+
let client = reqwest::Client::builder()
24+
.timeout(std::time::Duration::from_secs(30))
25+
.build()?;
26+
27+
let response = client
28+
.get(&url)
29+
.send()
30+
.await
31+
.context("failed to fetch registry tags")?;
32+
33+
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
34+
// Try with anonymous token auth (Docker Hub style)
35+
return list_tags_with_token(&client, &registry, &repo).await;
36+
}
37+
38+
if !response.status().is_success() {
39+
bail!(
40+
"registry returned HTTP {}: {}",
41+
response.status(),
42+
response.text().await.unwrap_or_default()
43+
);
44+
}
45+
46+
let tag_list: TagList = response
47+
.json()
48+
.await
49+
.context("failed to parse registry tag list")?;
50+
51+
Ok(tag_list.tags.unwrap_or_default())
52+
}
53+
54+
/// Handle token-based auth (Docker Hub / registries requiring Bearer token).
55+
async fn list_tags_with_token(
56+
client: &reqwest::Client,
57+
registry: &str,
58+
repo: &str,
59+
) -> Result<Vec<String>> {
60+
// Fetch token from the registry's token endpoint
61+
let token_url =
62+
format!("https://{registry}/v2/token?service={registry}&scope=repository:{repo}:pull");
63+
let token_resp = client.get(&token_url).send().await;
64+
65+
// If the token endpoint doesn't exist, try the standard Docker Hub approach
66+
let token = match token_resp {
67+
Ok(resp) if resp.status().is_success() => {
68+
let token_data: TokenResponse = resp.json().await?;
69+
token_data.token
70+
}
71+
_ => {
72+
bail!("registry requires authentication but token exchange failed");
73+
}
74+
};
75+
76+
let url = format!("https://{registry}/v2/{repo}/tags/list");
77+
let response = client
78+
.get(&url)
79+
.bearer_auth(&token)
80+
.send()
81+
.await
82+
.context("failed to fetch registry tags with token")?;
83+
84+
if !response.status().is_success() {
85+
bail!(
86+
"registry returned HTTP {} after auth: {}",
87+
response.status(),
88+
response.text().await.unwrap_or_default()
89+
);
90+
}
91+
92+
let tag_list: TagList = response
93+
.json()
94+
.await
95+
.context("failed to parse registry tag list")?;
96+
97+
Ok(tag_list.tags.unwrap_or_default())
98+
}
99+
100+
/// Pull an image from registry and extract to the local image directory.
101+
///
102+
/// Uses `docker pull` + `docker create` + `docker export` to extract files.
103+
pub async fn pull_and_extract(image_ref: &str, tag: &str, image_path: &Path) -> Result<()> {
104+
let full_ref = format!("{image_ref}:{tag}");
105+
info!("pulling image {full_ref}");
106+
107+
// docker pull
108+
let output = Command::new("docker")
109+
.args(["pull", &full_ref])
110+
.stdout(Stdio::piped())
111+
.stderr(Stdio::piped())
112+
.output()
113+
.await
114+
.context("failed to execute docker pull")?;
115+
116+
if !output.status.success() {
117+
bail!(
118+
"docker pull failed: {}",
119+
String::from_utf8_lossy(&output.stderr)
120+
);
121+
}
122+
123+
// Determine output directory name from image metadata
124+
let output_dir = determine_output_dir(image_ref, tag, image_path).await?;
125+
if output_dir.exists() {
126+
bail!("image directory already exists: {}", output_dir.display());
127+
}
128+
129+
// Create temp dir, extract, then rename
130+
let tmp_dir = image_path.join(format!(".tmp-pull-{tag}"));
131+
if tmp_dir.exists() {
132+
fs_err::remove_dir_all(&tmp_dir).context("failed to clean up temp dir")?;
133+
}
134+
fs_err::create_dir_all(&tmp_dir)?;
135+
136+
// docker create (don't start)
137+
let output = Command::new("docker")
138+
.args(["create", &full_ref, "/nonexistent"])
139+
.stdout(Stdio::piped())
140+
.stderr(Stdio::piped())
141+
.output()
142+
.await
143+
.context("failed to create container")?;
144+
145+
if !output.status.success() {
146+
let _ = fs_err::remove_dir_all(&tmp_dir);
147+
bail!(
148+
"docker create failed: {}",
149+
String::from_utf8_lossy(&output.stderr)
150+
);
151+
}
152+
153+
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
154+
155+
// docker export | tar extract
156+
let result = extract_container(&container_id, &tmp_dir).await;
157+
158+
// Always clean up container
159+
let _ = Command::new("docker")
160+
.args(["rm", &container_id])
161+
.output()
162+
.await;
163+
164+
result?;
165+
166+
// Remove docker artifact directories (FROM scratch creates these)
167+
for dir in &["dev", "etc", "proc", "sys"] {
168+
let d = tmp_dir.join(dir);
169+
if d.is_dir() {
170+
let _ = fs_err::remove_dir(&d);
171+
}
172+
}
173+
174+
// Verify metadata.json exists
175+
if !tmp_dir.join("metadata.json").exists() {
176+
let _ = fs_err::remove_dir_all(&tmp_dir);
177+
bail!("pulled image does not contain metadata.json - not a valid dstack guest image");
178+
}
179+
180+
// Rename to final location
181+
fs_err::rename(&tmp_dir, &output_dir).with_context(|| {
182+
format!(
183+
"failed to rename {} to {}",
184+
tmp_dir.display(),
185+
output_dir.display()
186+
)
187+
})?;
188+
189+
info!("image extracted to {}", output_dir.display());
190+
Ok(())
191+
}
192+
193+
async fn extract_container(container_id: &str, dst: &Path) -> Result<()> {
194+
// Use shell pipe: docker export <id> | tar x -C <dst>
195+
let output = Command::new("sh")
196+
.args([
197+
"-c",
198+
&format!(
199+
"docker export {} | tar x -C {}",
200+
shell_escape(container_id),
201+
shell_escape(&dst.display().to_string()),
202+
),
203+
])
204+
.stdout(Stdio::piped())
205+
.stderr(Stdio::piped())
206+
.output()
207+
.await
208+
.context("failed to run docker export | tar")?;
209+
210+
if !output.status.success() {
211+
bail!(
212+
"docker export | tar failed: {}",
213+
String::from_utf8_lossy(&output.stderr)
214+
);
215+
}
216+
217+
Ok(())
218+
}
219+
220+
fn shell_escape(s: &str) -> String {
221+
format!("'{}'", s.replace('\'', "'\\''"))
222+
}
223+
224+
/// Determine output directory name. Read metadata.json from the image to get version,
225+
/// then construct the directory name as `dstack-{version}` or `dstack-{variant}-{version}`.
226+
async fn determine_output_dir(
227+
_image_ref: &str,
228+
tag: &str,
229+
image_path: &Path,
230+
) -> Result<std::path::PathBuf> {
231+
// Use tag as directory name, prefixed with "dstack-" if not already
232+
let dir_name = if tag.starts_with("dstack-") {
233+
tag.to_string()
234+
} else {
235+
format!("dstack-{tag}")
236+
};
237+
Ok(image_path.join(dir_name))
238+
}
239+
240+
/// Parse "registry.example.com/repo/name" into ("registry.example.com", "repo/name").
241+
fn parse_image_ref(image_ref: &str) -> Result<(String, String)> {
242+
let trimmed = image_ref
243+
.trim_start_matches("https://")
244+
.trim_start_matches("http://");
245+
246+
let first_slash = trimmed
247+
.find('/')
248+
.context("invalid image reference: no repository path")?;
249+
250+
let registry = &trimmed[..first_slash];
251+
let repo = &trimmed[first_slash + 1..];
252+
253+
if repo.is_empty() {
254+
bail!("invalid image reference: empty repository");
255+
}
256+
257+
Ok((registry.to_string(), repo.to_string()))
258+
}
259+
260+
#[derive(Deserialize)]
261+
struct TagList {
262+
tags: Option<Vec<String>>,
263+
}
264+
265+
#[derive(Deserialize)]
266+
struct TokenResponse {
267+
token: String,
268+
}
269+
270+
#[cfg(test)]
271+
mod tests {
272+
use super::*;
273+
274+
#[test]
275+
fn test_parse_image_ref() {
276+
let (reg, repo) = parse_image_ref("cr.kvin.wang/dstack/guest-image").unwrap();
277+
assert_eq!(reg, "cr.kvin.wang");
278+
assert_eq!(repo, "dstack/guest-image");
279+
}
280+
281+
#[test]
282+
fn test_parse_image_ref_with_scheme() {
283+
let (reg, repo) = parse_image_ref("https://ghcr.io/dstack-tee/guest-image").unwrap();
284+
assert_eq!(reg, "ghcr.io");
285+
assert_eq!(repo, "dstack-tee/guest-image");
286+
}
287+
}

vmm/src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ pub struct Config {
322322
#[serde(default)]
323323
pub node_name: String,
324324

325+
/// OCI image registry for guest images (e.g., "cr.kvin.wang/dstack/guest-image")
326+
#[serde(default)]
327+
pub image_registry: String,
328+
325329
/// The buffer size in VMM process for guest events
326330
pub event_buffer_size: usize,
327331

0 commit comments

Comments
 (0)