Skip to content

Commit f6852e1

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 abfebc0 commit f6852e1

11 files changed

Lines changed: 1279 additions & 3 deletions

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

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)