diff --git a/Cargo.lock b/Cargo.lock index 5d35982a..41fd05ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2678,6 +2678,7 @@ dependencies = [ "or-panic", "path-absolutize", "ra-rpc", + "reqwest", "rocket", "rocket-apitoken", "rocket-vsock-listener", diff --git a/vmm/Cargo.toml b/vmm/Cargo.toml index 4d166c6d..2640e576 100644 --- a/vmm/Cargo.toml +++ b/vmm/Cargo.toml @@ -55,6 +55,7 @@ fatfs.workspace = true fscommon.workspace = true or-panic.workspace = true url.workspace = true +reqwest.workspace = true [dev-dependencies] insta.workspace = true diff --git a/vmm/rpc/proto/vmm_rpc.proto b/vmm/rpc/proto/vmm_rpc.proto index fb22bdb9..30d64d3d 100644 --- a/vmm/rpc/proto/vmm_rpc.proto +++ b/vmm/rpc/proto/vmm_rpc.proto @@ -350,6 +350,11 @@ service Vmm { rpc SvStop(Id) returns (google.protobuf.Empty); // 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); } // DHCP lease event reported by the host DHCP server. @@ -365,6 +370,27 @@ 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; +} + +// 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/vmm/src/app.rs b/vmm/src/app.rs index 71ab5448..251cd75e 100644 --- a/vmm/src/app.rs +++ b/vmm/src/app.rs @@ -35,6 +35,7 @@ pub use qemu::{VmConfig, VmWorkDir}; mod id_pool; mod image; mod qemu; +pub(crate) mod registry; #[derive(Deserialize, Serialize, Debug, Clone)] pub struct PortMapping { @@ -124,6 +125,8 @@ pub struct App { pub supervisor: SupervisorClient, state: Arc>, forward_service: Arc>, + /// Tags currently being pulled from the image registry. + pub(crate) pulling_tags: Arc>>, } impl App { @@ -152,6 +155,7 @@ impl App { })), config: Arc::new(config), forward_service: Arc::new(tokio::sync::Mutex::new(ForwardService::new())), + pulling_tags: Arc::new(Mutex::new(std::collections::HashSet::new())), } } diff --git a/vmm/src/app/registry.rs b/vmm/src/app/registry.rs new file mode 100644 index 00000000..7680f5f9 --- /dev/null +++ b/vmm/src/app/registry.rs @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +use std::path::Path; +use std::process::Stdio; + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use tokio::process::Command; +use tracing::info; + +/// 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)?; + + // Try Docker Registry HTTP API v2 + let url = format!("https://{registry}/v2/{repo}/tags/list"); + info!("fetching registry tags from {url}"); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let response = client + .get(&url) + .send() + .await + .context("failed to fetch registry tags")?; + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + // Try with anonymous token auth (Docker Hub style) + 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: &reqwest::Client, + registry: &str, + repo: &str, +) -> Result> { + // Fetch token from the registry's token endpoint + let token_url = + format!("https://{registry}/v2/token?service={registry}&scope=repository:{repo}:pull"); + let token_resp = client.get(&token_url).send().await; + + // If the token endpoint doesn't exist, try the standard Docker Hub approach + let token = match token_resp { + Ok(resp) if resp.status().is_success() => { + let token_data: TokenResponse = resp.json().await?; + token_data.token + } + _ => { + bail!("registry requires authentication but token exchange failed"); + } + }; + + 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()) +} + +/// Pull an image from registry and extract to the local image directory. +/// +/// Uses `docker pull` + `docker create` + `docker export` to extract files. +pub async fn pull_and_extract(image_ref: &str, tag: &str, image_path: &Path) -> Result<()> { + let full_ref = format!("{image_ref}:{tag}"); + info!("pulling image {full_ref}"); + + // docker pull + let output = Command::new("docker") + .args(["pull", &full_ref]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .context("failed to execute docker pull")?; + + if !output.status.success() { + bail!( + "docker pull failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // Determine output directory name from image metadata + let output_dir = determine_output_dir(image_ref, tag, image_path).await?; + if output_dir.exists() { + bail!("image directory already exists: {}", output_dir.display()); + } + + // Create temp dir, extract, then rename + 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 temp dir")?; + } + fs_err::create_dir_all(&tmp_dir)?; + + // docker create (don't start) + let output = Command::new("docker") + .args(["create", &full_ref, "/nonexistent"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .context("failed to create container")?; + + if !output.status.success() { + let _ = fs_err::remove_dir_all(&tmp_dir); + bail!( + "docker create failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // docker export | tar extract + let result = extract_container(&container_id, &tmp_dir).await; + + // Always clean up container + let _ = Command::new("docker") + .args(["rm", &container_id]) + .output() + .await; + + result?; + + // Remove docker artifact directories (FROM scratch creates these) + for dir in &["dev", "etc", "proc", "sys"] { + let d = tmp_dir.join(dir); + if d.is_dir() { + let _ = fs_err::remove_dir(&d); + } + } + + // 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"); + } + + // Rename 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(()) +} + +async fn extract_container(container_id: &str, dst: &Path) -> Result<()> { + // Use shell pipe: docker export | tar x -C + let output = Command::new("sh") + .args([ + "-c", + &format!( + "docker export {} | tar x -C {}", + shell_escape(container_id), + shell_escape(&dst.display().to_string()), + ), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .context("failed to run docker export | tar")?; + + if !output.status.success() { + bail!( + "docker export | tar failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) +} + +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Determine output directory name. Read metadata.json from the image to get version, +/// then construct the directory name as `dstack-{version}` or `dstack-{variant}-{version}`. +async fn determine_output_dir( + _image_ref: &str, + tag: &str, + image_path: &Path, +) -> Result { + // Use tag as directory name, prefixed with "dstack-" if not already + let dir_name = if tag.starts_with("dstack-") { + tag.to_string() + } else { + format!("dstack-{tag}") + }; + Ok(image_path.join(dir_name)) +} + +/// Parse "registry.example.com/repo/name" into ("registry.example.com", "repo/name"). +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 registry = &trimmed[..first_slash]; + let repo = &trimmed[first_slash + 1..]; + + if repo.is_empty() { + bail!("invalid image reference: empty repository"); + } + + Ok((registry.to_string(), repo.to_string())) +} + +#[derive(Deserialize)] +struct TagList { + tags: Option>, +} + +#[derive(Deserialize)] +struct TokenResponse { + token: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_image_ref() { + 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_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"); + } +} diff --git a/vmm/src/config.rs b/vmm/src/config.rs index bb8c218e..f2cfa444 100644 --- a/vmm/src/config.rs +++ b/vmm/src/config.rs @@ -322,6 +322,10 @@ pub struct Config { #[serde(default)] pub node_name: String, + /// OCI image registry for guest images (e.g., "cr.kvin.wang/dstack/guest-image") + #[serde(default)] + pub image_registry: String, + /// The buffer size in VMM process for guest events pub event_buffer_size: usize, diff --git a/vmm/src/console_v1.html b/vmm/src/console_v1.html index 7951f5d6..489b2add 100644 --- a/vmm/src/console_v1.html +++ b/vmm/src/console_v1.html @@ -3740,6 +3740,52 @@

Derive VM

features.push("tcbinfo"); return features.length > 0 ? features.join(', ') : 'None'; } + // ── Image Registry ───────────────────────────────────────────── + const showImageRegistry = ref(false); + const registryImages = ref([]); + const registryLoading = ref(false); + let registryRefreshTimer = null; + async function loadRegistryImages() { + registryLoading.value = true; + try { + const data = await vmmRpc.listRegistryImages({}); + registryImages.value = (data.images || []).sort((a, b) => { + // 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) => img.pulling)) { + loadImages(); + } + } + catch (error) { + recordError('failed to load registry images', error); + } + finally { + registryLoading.value = false; + } + } + async function pullRegistryImage(tag) { + try { + await vmmRpc.pullRegistryImage({ tag }); + // Immediately refresh to show pulling state + await loadRegistryImages(); + } + catch (error) { + recordError(`failed to pull image ${tag}`, error); + } + } + async function openImageRegistry() { + showImageRegistry.value = true; + await loadRegistryImages(); + registryRefreshTimer = setInterval(loadRegistryImages, 3000); + } + watch(showImageRegistry, (open) => { + if (!open && registryRefreshTimer) { + clearInterval(registryRefreshTimer); + registryRefreshTimer = null; + } + }); // ── Process Manager ───────────────────────────────────────────── const showProcessManager = ref(false); const supervisorProcesses = ref([]); @@ -3896,6 +3942,12 @@

Derive VM

svStatusClass, svIsRunning, svIsStopped, + showImageRegistry, + registryImages, + registryLoading, + loadRegistryImages, + pullRegistryImage, + openImageRegistry, }; } @@ -13761,6 +13813,66 @@

Derive VM

* @returns {Promise} Promise * @variation 2 */ + /** + * Callback as used by {@link vmm.Vmm#listRegistryImages}. + * @memberof vmm.Vmm + * @typedef ListRegistryImagesCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {vmm.RegistryImageListResponse} [response] RegistryImageListResponse + */ + /** + * Calls ListRegistryImages. + * @function listRegistryImages + * @memberof vmm.Vmm + * @instance + * @param {google.protobuf.IEmpty} request Empty message or plain object + * @param {vmm.Vmm.ListRegistryImagesCallback} callback Node-style callback called with the error, if any, and RegistryImageListResponse + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(Vmm.prototype.listRegistryImages = function listRegistryImages(request, callback) { + return this.rpcCall(listRegistryImages, $root.google.protobuf.Empty, $root.vmm.RegistryImageListResponse, request, callback); + }, "name", { value: "ListRegistryImages" }); + /** + * Calls ListRegistryImages. + * @function listRegistryImages + * @memberof vmm.Vmm + * @instance + * @param {google.protobuf.IEmpty} request Empty message or plain object + * @returns {Promise} Promise + * @variation 2 + */ + /** + * Callback as used by {@link vmm.Vmm#pullRegistryImage}. + * @memberof vmm.Vmm + * @typedef PullRegistryImageCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {google.protobuf.Empty} [response] Empty + */ + /** + * Calls PullRegistryImage. + * @function pullRegistryImage + * @memberof vmm.Vmm + * @instance + * @param {vmm.IPullRegistryImageRequest} request PullRegistryImageRequest message or plain object + * @param {vmm.Vmm.PullRegistryImageCallback} callback Node-style callback called with the error, if any, and Empty + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(Vmm.prototype.pullRegistryImage = function pullRegistryImage(request, callback) { + return this.rpcCall(pullRegistryImage, $root.vmm.PullRegistryImageRequest, $root.google.protobuf.Empty, request, callback); + }, "name", { value: "PullRegistryImage" }); + /** + * Calls PullRegistryImage. + * @function pullRegistryImage + * @memberof vmm.Vmm + * @instance + * @param {vmm.IPullRegistryImageRequest} request PullRegistryImageRequest message or plain object + * @returns {Promise} Promise + * @variation 2 + */ return Vmm; })(); vmm.DhcpLeaseRequest = (function () { @@ -14187,6 +14299,642 @@

Derive VM

}; return SvListResponse; })(); + vmm.RegistryImageListResponse = (function () { + /** + * Properties of a RegistryImageListResponse. + * @memberof vmm + * @interface IRegistryImageListResponse + * @property {Array.|null} [images] RegistryImageListResponse images + */ + /** + * Constructs a new RegistryImageListResponse. + * @memberof vmm + * @classdesc Represents a RegistryImageListResponse. + * @implements IRegistryImageListResponse + * @constructor + * @param {vmm.IRegistryImageListResponse=} [properties] Properties to set + */ + function RegistryImageListResponse(properties) { + this.images = []; + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + /** + * RegistryImageListResponse images. + * @member {Array.} images + * @memberof vmm.RegistryImageListResponse + * @instance + */ + RegistryImageListResponse.prototype.images = $util.emptyArray; + /** + * Creates a new RegistryImageListResponse instance using the specified properties. + * @function create + * @memberof vmm.RegistryImageListResponse + * @static + * @param {vmm.IRegistryImageListResponse=} [properties] Properties to set + * @returns {vmm.RegistryImageListResponse} RegistryImageListResponse instance + */ + RegistryImageListResponse.create = function create(properties) { + return new RegistryImageListResponse(properties); + }; + /** + * Encodes the specified RegistryImageListResponse message. Does not implicitly {@link vmm.RegistryImageListResponse.verify|verify} messages. + * @function encode + * @memberof vmm.RegistryImageListResponse + * @static + * @param {vmm.IRegistryImageListResponse} message RegistryImageListResponse message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RegistryImageListResponse.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.images != null && message.images.length) + for (var i = 0; i < message.images.length; ++i) + $root.vmm.RegistryImageInfo.encode(message.images[i], writer.uint32(/* id 1, wireType 2 =*/ 10).fork()).ldelim(); + return writer; + }; + /** + * Encodes the specified RegistryImageListResponse message, length delimited. Does not implicitly {@link vmm.RegistryImageListResponse.verify|verify} messages. + * @function encodeDelimited + * @memberof vmm.RegistryImageListResponse + * @static + * @param {vmm.IRegistryImageListResponse} message RegistryImageListResponse message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RegistryImageListResponse.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + /** + * Decodes a RegistryImageListResponse message from the specified reader or buffer. + * @function decode + * @memberof vmm.RegistryImageListResponse + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {vmm.RegistryImageListResponse} RegistryImageListResponse + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RegistryImageListResponse.decode = function decode(reader, length, error) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.vmm.RegistryImageListResponse(); + while (reader.pos < end) { + var tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.images && message.images.length)) + message.images = []; + message.images.push($root.vmm.RegistryImageInfo.decode(reader, reader.uint32())); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + /** + * Decodes a RegistryImageListResponse message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof vmm.RegistryImageListResponse + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {vmm.RegistryImageListResponse} RegistryImageListResponse + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RegistryImageListResponse.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + /** + * Verifies a RegistryImageListResponse message. + * @function verify + * @memberof vmm.RegistryImageListResponse + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + RegistryImageListResponse.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.images != null && message.hasOwnProperty("images")) { + if (!Array.isArray(message.images)) + return "images: array expected"; + for (var i = 0; i < message.images.length; ++i) { + var error = $root.vmm.RegistryImageInfo.verify(message.images[i]); + if (error) + return "images." + error; + } + } + return null; + }; + /** + * Creates a RegistryImageListResponse message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof vmm.RegistryImageListResponse + * @static + * @param {Object.} object Plain object + * @returns {vmm.RegistryImageListResponse} RegistryImageListResponse + */ + RegistryImageListResponse.fromObject = function fromObject(object) { + if (object instanceof $root.vmm.RegistryImageListResponse) + return object; + var message = new $root.vmm.RegistryImageListResponse(); + if (object.images) { + if (!Array.isArray(object.images)) + throw TypeError(".vmm.RegistryImageListResponse.images: array expected"); + message.images = []; + for (var i = 0; i < object.images.length; ++i) { + if (typeof object.images[i] !== "object") + throw TypeError(".vmm.RegistryImageListResponse.images: object expected"); + message.images[i] = $root.vmm.RegistryImageInfo.fromObject(object.images[i]); + } + } + return message; + }; + /** + * Creates a plain object from a RegistryImageListResponse message. Also converts values to other types if specified. + * @function toObject + * @memberof vmm.RegistryImageListResponse + * @static + * @param {vmm.RegistryImageListResponse} message RegistryImageListResponse + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RegistryImageListResponse.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.arrays || options.defaults) + object.images = []; + if (message.images && message.images.length) { + object.images = []; + for (var j = 0; j < message.images.length; ++j) + object.images[j] = $root.vmm.RegistryImageInfo.toObject(message.images[j], options); + } + return object; + }; + /** + * Converts this RegistryImageListResponse to JSON. + * @function toJSON + * @memberof vmm.RegistryImageListResponse + * @instance + * @returns {Object.} JSON object + */ + RegistryImageListResponse.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + /** + * Gets the default type url for RegistryImageListResponse + * @function getTypeUrl + * @memberof vmm.RegistryImageListResponse + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RegistryImageListResponse.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/vmm.RegistryImageListResponse"; + }; + return RegistryImageListResponse; + })(); + vmm.RegistryImageInfo = (function () { + /** + * Properties of a RegistryImageInfo. + * @memberof vmm + * @interface IRegistryImageInfo + * @property {string|null} [tag] RegistryImageInfo tag + * @property {boolean|null} [local] RegistryImageInfo local + * @property {boolean|null} [pulling] RegistryImageInfo pulling + */ + /** + * Constructs a new RegistryImageInfo. + * @memberof vmm + * @classdesc Represents a RegistryImageInfo. + * @implements IRegistryImageInfo + * @constructor + * @param {vmm.IRegistryImageInfo=} [properties] Properties to set + */ + function RegistryImageInfo(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + /** + * RegistryImageInfo tag. + * @member {string} tag + * @memberof vmm.RegistryImageInfo + * @instance + */ + RegistryImageInfo.prototype.tag = ""; + /** + * RegistryImageInfo local. + * @member {boolean} local + * @memberof vmm.RegistryImageInfo + * @instance + */ + RegistryImageInfo.prototype.local = false; + /** + * RegistryImageInfo pulling. + * @member {boolean} pulling + * @memberof vmm.RegistryImageInfo + * @instance + */ + RegistryImageInfo.prototype.pulling = false; + /** + * Creates a new RegistryImageInfo instance using the specified properties. + * @function create + * @memberof vmm.RegistryImageInfo + * @static + * @param {vmm.IRegistryImageInfo=} [properties] Properties to set + * @returns {vmm.RegistryImageInfo} RegistryImageInfo instance + */ + RegistryImageInfo.create = function create(properties) { + return new RegistryImageInfo(properties); + }; + /** + * Encodes the specified RegistryImageInfo message. Does not implicitly {@link vmm.RegistryImageInfo.verify|verify} messages. + * @function encode + * @memberof vmm.RegistryImageInfo + * @static + * @param {vmm.IRegistryImageInfo} message RegistryImageInfo message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RegistryImageInfo.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.tag != null && Object.hasOwnProperty.call(message, "tag")) + writer.uint32(/* id 1, wireType 2 =*/ 10).string(message.tag); + if (message.local != null && Object.hasOwnProperty.call(message, "local")) + writer.uint32(/* id 2, wireType 0 =*/ 16).bool(message.local); + if (message.pulling != null && Object.hasOwnProperty.call(message, "pulling")) + writer.uint32(/* id 3, wireType 0 =*/ 24).bool(message.pulling); + return writer; + }; + /** + * Encodes the specified RegistryImageInfo message, length delimited. Does not implicitly {@link vmm.RegistryImageInfo.verify|verify} messages. + * @function encodeDelimited + * @memberof vmm.RegistryImageInfo + * @static + * @param {vmm.IRegistryImageInfo} message RegistryImageInfo message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RegistryImageInfo.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + /** + * Decodes a RegistryImageInfo message from the specified reader or buffer. + * @function decode + * @memberof vmm.RegistryImageInfo + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {vmm.RegistryImageInfo} RegistryImageInfo + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RegistryImageInfo.decode = function decode(reader, length, error) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.vmm.RegistryImageInfo(); + while (reader.pos < end) { + var tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.tag = reader.string(); + break; + } + case 2: { + message.local = reader.bool(); + break; + } + case 3: { + message.pulling = reader.bool(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + /** + * Decodes a RegistryImageInfo message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof vmm.RegistryImageInfo + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {vmm.RegistryImageInfo} RegistryImageInfo + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RegistryImageInfo.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + /** + * Verifies a RegistryImageInfo message. + * @function verify + * @memberof vmm.RegistryImageInfo + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + RegistryImageInfo.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.tag != null && message.hasOwnProperty("tag")) + if (!$util.isString(message.tag)) + return "tag: string expected"; + if (message.local != null && message.hasOwnProperty("local")) + if (typeof message.local !== "boolean") + return "local: boolean expected"; + if (message.pulling != null && message.hasOwnProperty("pulling")) + if (typeof message.pulling !== "boolean") + return "pulling: boolean expected"; + return null; + }; + /** + * Creates a RegistryImageInfo message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof vmm.RegistryImageInfo + * @static + * @param {Object.} object Plain object + * @returns {vmm.RegistryImageInfo} RegistryImageInfo + */ + RegistryImageInfo.fromObject = function fromObject(object) { + if (object instanceof $root.vmm.RegistryImageInfo) + return object; + var message = new $root.vmm.RegistryImageInfo(); + if (object.tag != null) + message.tag = String(object.tag); + if (object.local != null) + message.local = Boolean(object.local); + if (object.pulling != null) + message.pulling = Boolean(object.pulling); + return message; + }; + /** + * Creates a plain object from a RegistryImageInfo message. Also converts values to other types if specified. + * @function toObject + * @memberof vmm.RegistryImageInfo + * @static + * @param {vmm.RegistryImageInfo} message RegistryImageInfo + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RegistryImageInfo.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + object.tag = ""; + object.local = false; + object.pulling = false; + } + if (message.tag != null && message.hasOwnProperty("tag")) + object.tag = message.tag; + if (message.local != null && message.hasOwnProperty("local")) + object.local = message.local; + if (message.pulling != null && message.hasOwnProperty("pulling")) + object.pulling = message.pulling; + return object; + }; + /** + * Converts this RegistryImageInfo to JSON. + * @function toJSON + * @memberof vmm.RegistryImageInfo + * @instance + * @returns {Object.} JSON object + */ + RegistryImageInfo.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + /** + * Gets the default type url for RegistryImageInfo + * @function getTypeUrl + * @memberof vmm.RegistryImageInfo + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RegistryImageInfo.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/vmm.RegistryImageInfo"; + }; + return RegistryImageInfo; + })(); + vmm.PullRegistryImageRequest = (function () { + /** + * Properties of a PullRegistryImageRequest. + * @memberof vmm + * @interface IPullRegistryImageRequest + * @property {string|null} [tag] PullRegistryImageRequest tag + */ + /** + * Constructs a new PullRegistryImageRequest. + * @memberof vmm + * @classdesc Represents a PullRegistryImageRequest. + * @implements IPullRegistryImageRequest + * @constructor + * @param {vmm.IPullRegistryImageRequest=} [properties] Properties to set + */ + function PullRegistryImageRequest(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + /** + * PullRegistryImageRequest tag. + * @member {string} tag + * @memberof vmm.PullRegistryImageRequest + * @instance + */ + PullRegistryImageRequest.prototype.tag = ""; + /** + * Creates a new PullRegistryImageRequest instance using the specified properties. + * @function create + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {vmm.IPullRegistryImageRequest=} [properties] Properties to set + * @returns {vmm.PullRegistryImageRequest} PullRegistryImageRequest instance + */ + PullRegistryImageRequest.create = function create(properties) { + return new PullRegistryImageRequest(properties); + }; + /** + * Encodes the specified PullRegistryImageRequest message. Does not implicitly {@link vmm.PullRegistryImageRequest.verify|verify} messages. + * @function encode + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {vmm.IPullRegistryImageRequest} message PullRegistryImageRequest message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + PullRegistryImageRequest.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.tag != null && Object.hasOwnProperty.call(message, "tag")) + writer.uint32(/* id 1, wireType 2 =*/ 10).string(message.tag); + return writer; + }; + /** + * Encodes the specified PullRegistryImageRequest message, length delimited. Does not implicitly {@link vmm.PullRegistryImageRequest.verify|verify} messages. + * @function encodeDelimited + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {vmm.IPullRegistryImageRequest} message PullRegistryImageRequest message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + PullRegistryImageRequest.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + /** + * Decodes a PullRegistryImageRequest message from the specified reader or buffer. + * @function decode + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {vmm.PullRegistryImageRequest} PullRegistryImageRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + PullRegistryImageRequest.decode = function decode(reader, length, error) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.vmm.PullRegistryImageRequest(); + while (reader.pos < end) { + var tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.tag = reader.string(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + /** + * Decodes a PullRegistryImageRequest message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {vmm.PullRegistryImageRequest} PullRegistryImageRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + PullRegistryImageRequest.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + /** + * Verifies a PullRegistryImageRequest message. + * @function verify + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + PullRegistryImageRequest.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.tag != null && message.hasOwnProperty("tag")) + if (!$util.isString(message.tag)) + return "tag: string expected"; + return null; + }; + /** + * Creates a PullRegistryImageRequest message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {Object.} object Plain object + * @returns {vmm.PullRegistryImageRequest} PullRegistryImageRequest + */ + PullRegistryImageRequest.fromObject = function fromObject(object) { + if (object instanceof $root.vmm.PullRegistryImageRequest) + return object; + var message = new $root.vmm.PullRegistryImageRequest(); + if (object.tag != null) + message.tag = String(object.tag); + return message; + }; + /** + * Creates a plain object from a PullRegistryImageRequest message. Also converts values to other types if specified. + * @function toObject + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {vmm.PullRegistryImageRequest} message PullRegistryImageRequest + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + PullRegistryImageRequest.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) + object.tag = ""; + if (message.tag != null && message.hasOwnProperty("tag")) + object.tag = message.tag; + return object; + }; + /** + * Converts this PullRegistryImageRequest to JSON. + * @function toJSON + * @memberof vmm.PullRegistryImageRequest + * @instance + * @returns {Object.} JSON object + */ + PullRegistryImageRequest.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + /** + * Gets the default type url for PullRegistryImageRequest + * @function getTypeUrl + * @memberof vmm.PullRegistryImageRequest + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + PullRegistryImageRequest.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/vmm.PullRegistryImageRequest"; + }; + return PullRegistryImageRequest; + })(); vmm.SvProcessInfo = (function () { /** * Properties of a SvProcessInfo. @@ -17619,7 +18367,7 @@

Derive VM

}, map: {"protobufjs/minimal":"node_modules/protobufjs/minimal.js"} }, 'build/ts/templates/app.html': { factory: function(module, exports, require) { -module.exports = "\n\n
\n
\n
\n
\n

dstack-vmm

\n \n v{{ version.version }}\n \n \n
\n
\n \n
\n \n
\n \n \n \n \n \n
\n
\n
\n
\n
\n\n \n\n \n\n \n\n
\n
\n
\n \n \n \n \n \n \n
\n
\n Total Instances:\n {{ totalVMs }}\n
\n
\n
\n
\n \n
\n \n /\n {{ maxPage || 1 }}\n
\n \n \n
\n
\n
\n\n
\n
\n
\n
Name
\n
Status
\n
Uptime
\n
View
\n
Actions
\n
\n\n
\n
\n
\n \n
\n
\n {{ vm.name }}\n
\n
\n \n \n {{ vmStatus(vm) }}\n \n
\n
{{ vm.status !== 'stopped' ? shortUptime(vm.uptime) : '-' }}
\n
\n Logs\n Stderr\n Board\n
\n
\n
\n \n
\n \n \n \n \n \n \n
\n
\n
\n
\n\n
\n
\n
\n VM ID\n
\n {{ vm.id }}\n \n
\n
\n
\n Instance ID\n
\n {{ vm.instance_id }}\n \n
\n -\n
\n
\n App ID\n
\n {{ vm.app_id }}\n \n
\n -\n
\n
\n Image\n {{ vm.configuration?.image }}\n
\n
\n vCPUs\n {{ vm.configuration?.vcpu }}\n
\n
\n Memory\n {{ formatMemory(vm.configuration?.memory) }}\n
\n
\n Swap\n {{ formatMemory(bytesToMB(vm.configuration.swap_size)) }}\n
\n
\n Disk Size\n {{ vm.configuration?.disk_size }} GB\n
\n
\n Disk Type\n {{ vm.configuration?.disk_type || 'virtio-pci' }}\n
\n
\n TEE\n {{ vm.configuration?.no_tee ? 'Disabled' : 'Enabled' }}\n
\n
\n GPUs\n
\n \n All GPUs\n \n
\n
\n \n {{ gpu.slot || gpu.product_id || ('GPU #' + (index + 1)) }}\n \n
\n
\n None\n
\n
\n
\n\n
\n

Port Mappings

\n
\n {{\n port.host_address === '127.0.0.1'\n ? 'Local'\n : (port.host_address === '0.0.0.0' ? 'Public' : port.host_address)\n }}\n {{ port.protocol.toUpperCase() }}: {{ port.host_port }} → {{ port.vm_port }}\n
\n
\n\n
\n

Features

\n {{ getVmFeatures(vm) }}\n
\n\n
\n

Network Interfaces

\n
\n
\n
\n
\n \n \n \n \n {{ iface.name }}\n
\n
\n
\n
\n MAC Address\n {{ iface.mac || '-' }}\n
\n
\n IP Address\n {{ iface.addresses.map(addr => addr.address + '/' + addr.prefix).join('\\n') || '-' }}\n
\n
\n
\n
\n \n \n \n
\n
\n RX\n {{ iface.rx_bytes }} bytes\n 0\">({{ iface.rx_errors }} errors)\n
\n
\n
\n
\n \n \n \n
\n
\n TX\n {{ iface.tx_bytes }} bytes\n 0\">({{ iface.tx_errors }} errors)\n
\n
\n
\n
\n
\n
\n
\n

\n \n \n \n \n WireGuard Info\n

\n
{{ networkInfo[vm.id].wg_info }}
\n
\n
\n\n
\n
\n

App Compose

\n
\n \n \n
\n
\n
\n
{{ vm.appCompose?.docker_compose_file || 'Docker Compose content not available' }}
\n
\n
\n\n
\n
\n

User Config

\n \n
\n
{{ vm.configuration.user_config }}
\n
\n\n
\n \n \n \n
\n
\n
\n
\n\n
\n
\n

Supervisor Processes

\n
\n \n \n \n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
NameIDStatusPIDActions
{{ p.name }}{{ p.id }}\n \n \n {{ p.status }}\n \n {{ p.pid || '-' }}\n \n \n
\n
No processes found
\n
\n
\n\n
\n
\n \n
\n
\n
\n \n
\n
\n
\n {{ errorMessage }}\n \n
\n
\n
\n"; +module.exports = "\n\n
\n
\n
\n
\n

dstack-vmm

\n \n v{{ version.version }}\n \n \n
\n
\n \n
\n \n
\n \n \n \n \n \n \n
\n
\n
\n
\n
\n\n \n\n \n\n \n\n
\n
\n
\n \n \n \n \n \n \n
\n
\n Total Instances:\n {{ totalVMs }}\n
\n
\n
\n
\n \n
\n \n /\n {{ maxPage || 1 }}\n
\n \n \n
\n
\n
\n\n
\n
\n
\n
Name
\n
Status
\n
Uptime
\n
View
\n
Actions
\n
\n\n
\n
\n
\n \n
\n
\n {{ vm.name }}\n
\n
\n \n \n {{ vmStatus(vm) }}\n \n
\n
{{ vm.status !== 'stopped' ? shortUptime(vm.uptime) : '-' }}
\n
\n Logs\n Stderr\n Board\n
\n
\n
\n \n
\n \n \n \n \n \n \n
\n
\n
\n
\n\n
\n
\n
\n VM ID\n
\n {{ vm.id }}\n \n
\n
\n
\n Instance ID\n
\n {{ vm.instance_id }}\n \n
\n -\n
\n
\n App ID\n
\n {{ vm.app_id }}\n \n
\n -\n
\n
\n Image\n {{ vm.configuration?.image }}\n
\n
\n vCPUs\n {{ vm.configuration?.vcpu }}\n
\n
\n Memory\n {{ formatMemory(vm.configuration?.memory) }}\n
\n
\n Swap\n {{ formatMemory(bytesToMB(vm.configuration.swap_size)) }}\n
\n
\n Disk Size\n {{ vm.configuration?.disk_size }} GB\n
\n
\n Disk Type\n {{ vm.configuration?.disk_type || 'virtio-pci' }}\n
\n
\n TEE\n {{ vm.configuration?.no_tee ? 'Disabled' : 'Enabled' }}\n
\n
\n GPUs\n
\n \n All GPUs\n \n
\n
\n \n {{ gpu.slot || gpu.product_id || ('GPU #' + (index + 1)) }}\n \n
\n
\n None\n
\n
\n
\n\n
\n

Port Mappings

\n
\n {{\n port.host_address === '127.0.0.1'\n ? 'Local'\n : (port.host_address === '0.0.0.0' ? 'Public' : port.host_address)\n }}\n {{ port.protocol.toUpperCase() }}: {{ port.host_port }} → {{ port.vm_port }}\n
\n
\n\n
\n

Features

\n {{ getVmFeatures(vm) }}\n
\n\n
\n

Network Interfaces

\n
\n
\n
\n
\n \n \n \n \n {{ iface.name }}\n
\n
\n
\n
\n MAC Address\n {{ iface.mac || '-' }}\n
\n
\n IP Address\n {{ iface.addresses.map(addr => addr.address + '/' + addr.prefix).join('\\n') || '-' }}\n
\n
\n
\n
\n \n \n \n
\n
\n RX\n {{ iface.rx_bytes }} bytes\n 0\">({{ iface.rx_errors }} errors)\n
\n
\n
\n
\n \n \n \n
\n
\n TX\n {{ iface.tx_bytes }} bytes\n 0\">({{ iface.tx_errors }} errors)\n
\n
\n
\n
\n
\n
\n
\n

\n \n \n \n \n WireGuard Info\n

\n
{{ networkInfo[vm.id].wg_info }}
\n
\n
\n\n
\n
\n

App Compose

\n
\n \n \n
\n
\n
\n
{{ vm.appCompose?.docker_compose_file || 'Docker Compose content not available' }}
\n
\n
\n\n
\n
\n

User Config

\n \n
\n
{{ vm.configuration.user_config }}
\n
\n\n
\n \n \n \n
\n
\n
\n
\n\n
\n
\n

Image Registry

\n
\n \n \n
\n
\n
\n
Loading registry tags...
\n
\n No images found. Make sure image_registry is configured in vmm.toml.\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
TagStatusActions
{{ img.tag }}\n \n \n Pulling...\n \n \n \n Local\n \n \n \n Remote\n \n \n \n Pull\n \n Downloading\n Downloaded\n
\n
\n
\n\n
\n
\n

Supervisor Processes

\n
\n \n \n \n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
NameIDStatusPIDActions
{{ p.name }}{{ p.id }}\n \n \n {{ p.status }}\n \n {{ p.pid || '-' }}\n \n \n
\n
No processes found
\n
\n
\n\n
\n
\n \n
\n
\n
\n \n
\n
\n
\n {{ errorMessage }}\n \n
\n
\n
\n"; }, map: {} } }; const cache = {}; diff --git a/vmm/src/main_service.rs b/vmm/src/main_service.rs index 1fb98b43..0cae06b2 100644 --- a/vmm/src/main_service.rs +++ b/vmm/src/main_service.rs @@ -12,11 +12,13 @@ use dstack_vmm_rpc::vmm_server::{VmmRpc, VmmServer}; use dstack_vmm_rpc::{ AppId, ComposeHash as RpcComposeHash, DhcpLeaseRequest, GatewaySettings, GetInfoResponse, GetMetaResponse, Id, ImageInfo as RpcImageInfo, ImageListResponse, KmsSettings, - ListGpusResponse, PublicKeyResponse, ReloadVmsResponse, ResizeVmRequest, ResourcesSettings, + ListGpusResponse, PublicKeyResponse, PullRegistryImageRequest, RegistryImageInfo, + RegistryImageListResponse, ReloadVmsResponse, ResizeVmRequest, ResourcesSettings, StatusRequest, StatusResponse, SvListResponse, SvProcessInfo, UpdateVmRequest, VersionResponse, VmConfiguration, }; use fs_err as fs; +use or_panic::ResultOrPanic; use ra_rpc::{CallContext, RpcCall}; use tracing::{info, warn}; @@ -613,6 +615,85 @@ impl VmmRpc for RpcHandler { self.app.supervisor.remove(&request.id).await?; 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 pulling_tags = self.app.pulling_tags.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 = pulling_tags.contains(&tag); + RegistryImageInfo { + tag, + local: is_local, + pulling: is_pulling, + } + }) + .collect(); + + Ok(RegistryImageListResponse { images }) + } + + 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 pulling = self.app.pulling_tags.lock().or_panic("mutex poisoned"); + if !pulling.insert(request.tag.clone()) { + bail!("image {} is already being pulled", request.tag); + } + } + + // Spawn background task + let tag = request.tag.clone(); + let registry = registry.clone(); + let image_path = self.app.config.image_path.clone(); + let pulling_tags = self.app.pulling_tags.clone(); + + info!("starting background pull for {tag}"); + tokio::spawn(async move { + let result = crate::app::registry::pull_and_extract(®istry, &tag, &image_path).await; + + // Remove from pulling set + { + let mut pulling = pulling_tags.lock().unwrap_or_else(|e| e.into_inner()); + pulling.remove(&tag); + } + + match result { + Ok(()) => info!("registry image {tag} pulled successfully"), + Err(e) => tracing::error!("failed to pull registry image {tag}: {e:#}"), + } + }); + + Ok(()) + } } impl RpcCall for RpcHandler { diff --git a/vmm/ui/src/composables/useVmManager.ts b/vmm/ui/src/composables/useVmManager.ts index b4b41abe..c4d3a294 100644 --- a/vmm/ui/src/composables/useVmManager.ts +++ b/vmm/ui/src/composables/useVmManager.ts @@ -1475,6 +1475,54 @@ 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 }>); + const registryLoading = ref(false); + let registryRefreshTimer: ReturnType | null = null; + + async function loadRegistryImages() { + 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) { + try { + await vmmRpc.pullRegistryImage({ tag }); + // Immediately refresh to show pulling state + await loadRegistryImages(); + } catch (error) { + recordError(`failed to pull image ${tag}`, error); + } + } + + async function openImageRegistry() { + showImageRegistry.value = true; + await loadRegistryImages(); + registryRefreshTimer = setInterval(loadRegistryImages, 3000); + } + + watch(showImageRegistry, (open) => { + if (!open && registryRefreshTimer) { + clearInterval(registryRefreshTimer); + registryRefreshTimer = null; + } + }); + // ── Process Manager ───────────────────────────────────────────── const showProcessManager = ref(false); const supervisorProcesses = ref([] as any[]); @@ -1636,6 +1684,12 @@ type CreateVmPayloadSource = { svStatusClass, svIsRunning, svIsStopped, + showImageRegistry, + registryImages, + registryLoading, + loadRegistryImages, + pullRegistryImage, + openImageRegistry, }; } diff --git a/vmm/ui/src/templates/app.html b/vmm/ui/src/templates/app.html index 439c59e0..1b08fdf6 100644 --- a/vmm/ui/src/templates/app.html +++ b/vmm/ui/src/templates/app.html @@ -35,6 +35,12 @@

dstack-vmm

Reload VMs + + + + +
+
Loading registry tags...
+
+ No images found. Make sure image_registry is configured in vmm.toml. +
+ + + + + + + + + + + + + + + +
TagStatusActions
{{ img.tag }} + + + Pulling... + + + + Local + + + + Remote + + + + Downloading + Downloaded +
+
+ +

Supervisor Processes

diff --git a/vmm/vmm.toml b/vmm/vmm.toml index 4b8514a4..fc28fabd 100644 --- a/vmm/vmm.toml +++ b/vmm/vmm.toml @@ -12,6 +12,8 @@ address = "unix:./vmm.sock" reuse = true kms_url = "http://127.0.0.1:8081" event_buffer_size = 20 +# OCI image registry for guest images (e.g., "cr.kvin.wang/dstack/guest-image") +image_registry = "" node_name = "" [cvm]