Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions crates/spur-cli/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ pub enum ImageCommand {
/// podman://image:tag — local Podman
image: String,

/// Target architecture (default: amd64)
#[arg(short = 'a', long, default_value = "amd64")]
/// Target architecture (default: host architecture)
#[arg(
short = 'a',
long,
default_value_t = std::env::consts::ARCH.to_string()
)]
arch: String,
},
/// List imported images.
Expand Down Expand Up @@ -85,7 +89,7 @@ async fn cmd_import(image: &str, arch: &str) -> Result<()> {
);

let image_dir = resolve_image_dir();
let path = spur_net::pull_image(image, &image_dir).await?;
let path = spur_net::pull_image(image, &image_dir, arch).await?;

let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
eprintln!(
Expand Down Expand Up @@ -333,6 +337,7 @@ fn cmd_remove(name: &str) -> Result<()> {
}

std::fs::remove_file(&path)?;
let _ = std::fs::remove_file(path.with_extension("sqsh.arch"));
eprintln!("Removed: {}", name);
Ok(())
}
Expand Down Expand Up @@ -422,3 +427,31 @@ fn is_dir_writable(path: &std::path::Path) -> bool {
.unwrap_or(false)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn import_defaults_to_host_arch() {
let args = ImageArgs::try_parse_from(["image", "import", "ubuntu:22.04"])
.expect("parse image import");

let ImageCommand::Import { arch, .. } = args.command else {
panic!("expected image import command");
};
assert_eq!(arch, std::env::consts::ARCH);
}

#[test]
fn import_accepts_explicit_arch() {
let args =
ImageArgs::try_parse_from(["image", "import", "ubuntu:22.04", "--arch", "arm64"])
.expect("parse image import");

let ImageCommand::Import { arch, .. } = args.command else {
panic!("expected image import command");
};
assert_eq!(arch, "arm64");
}
}
176 changes: 136 additions & 40 deletions crates/spur-net/src/oci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ struct Manifest {
// v1 compat: some registries return "fsLayers" instead
}

#[derive(Deserialize)]
struct ManifestList {
manifests: Vec<ManifestEntry>,
}

#[derive(Deserialize)]
struct ManifestEntry {
digest: String,
#[serde(default)]
platform: Option<Platform>,
}

#[derive(Deserialize)]
struct Platform {
architecture: String,
os: String,
}

#[derive(Deserialize)]
struct LayerDescriptor {
digest: String,
Expand Down Expand Up @@ -101,31 +119,42 @@ pub fn parse_image_ref(image: &str) -> ImageRef {
/// Pull an image from a registry and create a squashfs file.
///
/// Returns the path to the squashfs file.
pub async fn pull_image(image: &str, output_dir: &Path) -> anyhow::Result<PathBuf> {
pub async fn pull_image(image: &str, output_dir: &Path, arch: &str) -> anyhow::Result<PathBuf> {
let image_ref = parse_image_ref(image);
info!(
registry = %image_ref.registry,
repository = %image_ref.repository,
tag = %image_ref.tag,
architecture = arch,
"pulling image"
);

let sanitized = sanitize_name(image);
let sqsh_path = output_dir.join(format!("{}.sqsh", sanitized));
let arch_path = sqsh_path.with_extension("sqsh.arch");

if sqsh_path.exists() {
info!(path = %sqsh_path.display(), "image already exists");
return Ok(sqsh_path);
let cached_arch = std::fs::read_to_string(&arch_path).ok();
if cached_architecture_matches(cached_arch.as_deref(), arch) {
info!(path = %sqsh_path.display(), architecture = arch, "image already exists");
return Ok(sqsh_path);
}
info!(path = %sqsh_path.display(), architecture = arch, "replacing image for requested architecture");
}

std::fs::create_dir_all(output_dir)?;

// Create temp directory for rootfs assembly
let tmp_dir = output_dir.join(format!(".pulling_{}", sanitized));
if tmp_dir.exists() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the .pulling_<name> temp dir is deterministic per image, so two concurrent pulls of the same image race — and this unconditional remove_dir_all can wipe an in-flight pull's working tree. A PID/uuid suffix would make it safe. Low likelihood for interactive imports.

@hnotshe hnotshe Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared staging directory is gone. Each pull now uses a UUID-suffixed directory, and same-image pulls are serialized through the cache recheck and final publication, so concurrent imports cannot delete or interleave one another's artifacts.

std::fs::remove_dir_all(&tmp_dir)?;
}
let rootfs_dir = tmp_dir.join("rootfs");
let staged_sqsh_path = tmp_dir.join("image.sqsh");
let staged_arch_path = tmp_dir.join("image.sqsh.arch");
std::fs::create_dir_all(&rootfs_dir)?;

let result = pull_and_extract(&image_ref, &rootfs_dir).await;
let result = pull_and_extract(&image_ref, &rootfs_dir, arch).await;
if let Err(e) = &result {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(anyhow::anyhow!("{}", e));
Expand All @@ -136,7 +165,7 @@ pub async fn pull_image(image: &str, output_dir: &Path) -> anyhow::Result<PathBu
let mksquashfs_result = std::process::Command::new("mksquashfs")
.args([
rootfs_dir.to_str().unwrap(),
sqsh_path.to_str().unwrap(),
staged_sqsh_path.to_str().unwrap(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: to_str().unwrap() in a library crate trips the no-unwrap-in-lib guideline (AGENTS.md). Pre-existing, but since this line is in the diff, a .context("non-UTF-8 image path")? would be a cheap tidy-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaned this up by passing the filesystem paths directly to Command, removing both UTF-8 conversions and the library unwrap() calls.

"-noappend",
"-comp",
"zstd",
Expand Down Expand Up @@ -165,8 +194,20 @@ pub async fn pull_image(image: &str, output_dir: &Path) -> anyhow::Result<PathBu
}
}

// Clean up temp dir
let finalize_result = (|| -> anyhow::Result<()> {
std::fs::write(&staged_arch_path, oci_architecture(arch))?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sidecar is written as the requested arch unconditionally. On the single-manifest path (a registry returning an image manifest directly rather than an index), no platform verification runs, so the recorded arch could be wrong and defeat the rebuild-on-arch-change guard. Verifying the fetched manifest's platform (or documenting the limitation) would close this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by validating direct manifests through their OCI config blob before layer extraction. The config os and normalized architecture must match the requested Linux platform before the sidecar can be recorded, with regression coverage for matching and mismatched platforms.

std::fs::rename(&staged_sqsh_path, &sqsh_path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The install is two separate renames (.sqsh then .sqsh.arch). A crash between them leaves the new payload with a stale-or-missing arch sidecar. It is self-healing (the next pull rebuilds), but since the sidecar is the source of truth for the "rebuild on arch change" decision, writing the metadata first and renaming the payload in last (payload as the commit point) would make "sqsh present implies arch recorded" hold.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. First-time installs now publish the sidecar before exposing the payload. Replacements invalidate the old sidecar before publishing the new payload, then publish the new sidecar last, so an interruption forces a cache miss rather than pairing a payload with stale metadata. Both paths have filesystem-level regression coverage.

.with_context(|| format!("failed to install image at {}", sqsh_path.display()))?;
std::fs::rename(&staged_arch_path, &arch_path).with_context(|| {
format!(
"failed to record image architecture at {}",
arch_path.display()
)
})?;
Ok(())
})();
let _ = std::fs::remove_dir_all(&tmp_dir);
finalize_result?;

let size = std::fs::metadata(&sqsh_path).map(|m| m.len()).unwrap_or(0);
info!(
Expand All @@ -179,7 +220,11 @@ pub async fn pull_image(image: &str, output_dir: &Path) -> anyhow::Result<PathBu
}

/// Download manifest and layers, extract to rootfs directory.
async fn pull_and_extract(image_ref: &ImageRef, rootfs_dir: &Path) -> anyhow::Result<()> {
async fn pull_and_extract(
image_ref: &ImageRef,
rootfs_dir: &Path,
arch: &str,
) -> anyhow::Result<()> {
let client = reqwest::Client::builder().user_agent("spur/0.1").build()?;

// Get auth token
Expand Down Expand Up @@ -234,6 +279,7 @@ async fn pull_and_extract(image_ref: &ImageRef, rootfs_dir: &Path) -> anyhow::Re
&registry_url,
image_ref,
token.as_deref(),
arch,
)
.await?;
index
Expand Down Expand Up @@ -515,50 +561,51 @@ async fn get_auth_token(
Ok(None)
}

/// Resolve a manifest list (multi-arch) to a single amd64/linux manifest.
async fn resolve_manifest_list(
client: &reqwest::Client,
body: &str,
registry_url: &str,
image_ref: &ImageRef,
token: Option<&str>,
) -> anyhow::Result<Manifest> {
#[derive(Deserialize)]
struct ManifestList {
manifests: Vec<ManifestEntry>,
}
#[derive(Deserialize)]
struct ManifestEntry {
digest: String,
#[serde(default)]
platform: Option<Platform>,
}
#[derive(Deserialize)]
struct Platform {
architecture: String,
os: String,
fn oci_architecture(arch: &str) -> &str {
match arch {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
arch => arch,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the normalization fallthrough passes unknown arches through unchanged; combined with Platform not deserializing variant, 32-bit arm (v6/v7) cannot be disambiguated and the first arm entry wins. Fine for the amd64/arm64 common case — flagging for completeness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That limitation remains intentional here. The issue contract is architecture selection, while arm v6/v7 support needs a variant input and normalization contract rather than silently choosing the first arm entry. I am keeping variant handling out of this patch.

}
}

fn cached_architecture_matches(cached_arch: Option<&str>, requested_arch: &str) -> bool {
cached_arch.is_some_and(|arch| arch.trim() == oci_architecture(requested_arch))
}

fn manifest_digest_for_arch(body: &str, arch: &str) -> anyhow::Result<String> {
let list: ManifestList = serde_json::from_str(body).context("failed to parse manifest list")?;
let oci_arch = oci_architecture(arch);

// Find linux/amd64
let entry = list
.manifests
list.manifests
.iter()
.find(|m| {
m.platform
.find(|manifest| {
manifest
.platform
.as_ref()
.map(|p| p.architecture == "amd64" && p.os == "linux")
.unwrap_or(false)
.is_some_and(|platform| platform.architecture == oci_arch && platform.os == "linux")
})
.or_else(|| list.manifests.first())
.ok_or_else(|| anyhow::anyhow!("no linux/amd64 manifest found in manifest list"))?;
.map(|manifest| manifest.digest.clone())
.ok_or_else(|| anyhow::anyhow!("no linux/{arch} manifest found in manifest list"))
}

debug!(digest = %entry.digest, "resolved manifest list to platform manifest");
/// Resolve a manifest list (multi-arch) to a single Linux platform manifest.
async fn resolve_manifest_list(
client: &reqwest::Client,
body: &str,
registry_url: &str,
image_ref: &ImageRef,
token: Option<&str>,
arch: &str,
) -> anyhow::Result<Manifest> {
let digest = manifest_digest_for_arch(body, arch)?;

debug!(digest = %digest, architecture = arch, "resolved manifest list to platform manifest");

let url = format!(
"{}/v2/{}/manifests/{}",
registry_url, image_ref.repository, entry.digest
registry_url, image_ref.repository, digest
);
let mut req = client.get(&url).header(
ACCEPT,
Expand Down Expand Up @@ -644,6 +691,19 @@ mod tests {

use super::*;

const MULTI_ARCH_MANIFEST: &str = r#"{
"manifests": [
{
"digest": "sha256:amd64",
"platform": { "architecture": "amd64", "os": "linux" }
},
{
"digest": "sha256:arm64",
"platform": { "architecture": "arm64", "os": "linux" }
}
]
}"#;

#[test]
fn test_decode_registry_auth_b64_valid() {
// echo -n 'alice:secret' | base64 -w0
Expand Down Expand Up @@ -684,6 +744,42 @@ mod tests {
assert_eq!(super::decode_registry_auth_b64(&enc).as_deref(), Some(cred));
}

#[test]
fn test_manifest_digest_for_requested_arch() {
assert_eq!(
manifest_digest_for_arch(MULTI_ARCH_MANIFEST, "arm64").unwrap(),
"sha256:arm64"
);
}

#[test]
fn test_manifest_digest_normalizes_rust_arch_names() {
assert_eq!(
manifest_digest_for_arch(MULTI_ARCH_MANIFEST, "x86_64").unwrap(),
"sha256:amd64"
);
assert_eq!(
manifest_digest_for_arch(MULTI_ARCH_MANIFEST, "aarch64").unwrap(),
"sha256:arm64"
);
}

#[test]
fn test_cached_architecture_must_match_requested_arch() {
assert!(cached_architecture_matches(Some("arm64\n"), "aarch64"));
assert!(!cached_architecture_matches(Some("amd64"), "arm64"));
assert!(!cached_architecture_matches(None, "arm64"));
}

#[test]
fn test_manifest_digest_error_includes_requested_arch() {
let error = manifest_digest_for_arch(MULTI_ARCH_MANIFEST, "riscv64").unwrap_err();
assert_eq!(
error.to_string(),
"no linux/riscv64 manifest found in manifest list"
);
}

#[test]
fn test_parse_dockerhub_official() {
let r = parse_image_ref("ubuntu:22.04");
Expand Down
3 changes: 2 additions & 1 deletion crates/spurd/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1109,7 +1109,7 @@ pub fn container_init(
/// umoci, or enroot. Only needs mksquashfs (squashfs-tools).
pub async fn import_image(uri: &str) -> anyhow::Result<PathBuf> {
let dir = image_dir();
spur_net::pull_image(uri, &dir).await
spur_net::pull_image(uri, &dir, std::env::consts::ARCH).await

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requested arch is honored on the pull path, but import_image here hardcodes host arch and resolve_image matches purely by filename (never reading the .sqsh.arch sidecar) — so a cross-arch artifact could be launched on the wrong host unchecked. Likely out of scope for this PR's title, but worth a follow-up; the sidecar added here is the enabling primitive for that check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a separate execution-time validation boundary. This PR keeps daemon-side imports host-native and stays focused on honoring the requested architecture during import; changing resolve_image and launch behavior would expand beyond issue #343 acceptance criteria. I am leaving that follow-up out of this patch.

}

/// List imported images.
Expand Down Expand Up @@ -1144,6 +1144,7 @@ pub fn remove_image(name: &str) -> anyhow::Result<()> {
bail!("image '{}' not found", name);
}
std::fs::remove_file(&path)?;
let _ = std::fs::remove_file(path.with_extension("sqsh.arch"));
info!(name, "image removed");
Ok(())
}
Expand Down