1
0
Fork 0
mirror of https://github.com/imjasonh/krust synced 2026-07-08 14:55:35 +00:00

Merge pull request #31 from imjasonh/fix/layered-image-building

Fix layered image building with cross-registry support
This commit is contained in:
Jason Hall 2025-06-08 21:46:00 -04:00 committed by GitHub
commit 09f78c7047
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 629 additions and 99 deletions

View file

@ -1,4 +1,17 @@
fn main() {
println!("Hello from krust example!");
println!("This is running inside a container built with krust.");
println!("Current architecture: {}", std::env::consts::ARCH);
// Check for SSL_CERT_FILE environment variable (common in distroless images)
match std::env::var("SSL_CERT_FILE") {
Ok(value) => println!("✓ SSL_CERT_FILE found: {}", value),
Err(_) => panic!("✗ SSL_CERT_FILE not found (base image env not preserved)"),
}
// Check for /etc/os-release file (common in most Linux base images)
if std::path::Path::new("/etc/os-release").exists() {
println!("✓ /etc/os-release found (base image layers preserved)");
} else {
panic!("✗ /etc/os-release not found (base image layers not preserved)");
}
}

View file

@ -1,6 +1,7 @@
use anyhow::{Context, Result};
use flate2::write::GzEncoder;
use flate2::Compression;
use oci_distribution::secrets::RegistryAuth;
use serde::{Deserialize, Serialize};
use sha256::digest;
use std::fs::File;
@ -8,27 +9,25 @@ use std::io::Write;
use tar::Builder;
use tracing::{debug, info};
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageConfig {
pub architecture: String,
pub os: String,
pub config: Config,
pub rootfs: RootFs,
#[serde(default)]
pub history: Vec<History>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(rename = "Env")]
#[serde(rename = "Env", default)]
pub env: Vec<String>,
#[serde(rename = "Cmd")]
pub cmd: Option<Vec<String>>,
#[serde(rename = "WorkingDir")]
#[serde(rename = "WorkingDir", default)]
pub working_dir: String,
#[serde(rename = "User")]
#[serde(rename = "User", default)]
pub user: String,
}
@ -43,7 +42,9 @@ pub struct RootFs {
pub struct History {
pub created: String,
pub created_by: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub empty_layer: bool,
}
@ -83,18 +84,49 @@ impl ImageBuilder {
}
}
pub fn build(&self) -> Result<(Vec<u8>, Vec<u8>, Manifest)> {
pub async fn build(
&self,
registry_client: &mut crate::registry::RegistryClient,
auth: &RegistryAuth,
) -> Result<(Vec<u8>, Vec<u8>, Manifest)> {
info!("Building container image");
let (os, arch) = self.parse_platform()?;
let (_os, _arch) = self.parse_platform()?;
// Fetch base image data
info!(
"Fetching base image: {} for platform: {}",
self.base_image, self.platform
);
let (base_manifest, base_config) = registry_client
.fetch_image_data(&self.base_image, &self.platform, auth)
.await
.context("Failed to fetch base image data")?;
// Create application layer
let (layer_data, diff_id) = self.create_layer()?;
let layer_digest = format!("sha256:{}", digest(&layer_data));
let layer_size = layer_data.len() as i64;
let (app_layer_data, app_diff_id) = self.create_layer()?;
let app_layer_digest = format!("sha256:{}", digest(&app_layer_data));
let app_layer_size = app_layer_data.len() as i64;
// Create image config
let config = self.create_config(&os, &arch, &diff_id)?;
// Combine base image layers with application layer
let mut all_layers = Vec::new();
for layer in &base_manifest.layers {
all_layers.push(Descriptor {
media_type: layer.media_type.clone(),
size: layer.size,
digest: layer.digest.clone(),
});
}
// Add the application layer
all_layers.push(Descriptor {
media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string(),
size: app_layer_size,
digest: app_layer_digest,
});
// Create merged config
let config = self.create_layered_config(&base_config, &app_diff_id)?;
let config_data = serde_json::to_vec_pretty(&config)?;
let config_digest = format!("sha256:{}", digest(&config_data));
let config_size = config_data.len() as i64;
@ -108,14 +140,10 @@ impl ImageBuilder {
size: config_size,
digest: config_digest,
},
layers: vec![Descriptor {
media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string(),
size: layer_size,
digest: layer_digest,
}],
layers: all_layers,
};
Ok((config_data, layer_data, manifest))
Ok((config_data, app_layer_data, manifest))
}
fn parse_platform(&self) -> Result<(String, String)> {
@ -163,7 +191,11 @@ impl ImageBuilder {
Ok((compressed, diff_id))
}
fn create_config(&self, os: &str, arch: &str, layer_digest: &str) -> Result<ImageConfig> {
fn create_layered_config(
&self,
base_config: &ImageConfig,
app_diff_id: &str,
) -> Result<ImageConfig> {
let binary_name = self
.binary_path
.file_name()
@ -171,27 +203,225 @@ impl ImageBuilder {
.to_str()
.context("Invalid UTF-8 in binary name")?;
// Merge environment variables (preserve base + add our own)
let mut merged_env = base_config.config.env.clone();
// Add PATH if not present
if !merged_env.iter().any(|env| env.starts_with("PATH=")) {
merged_env.push(
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
);
}
// Combine diff_ids (base layers + app layer)
let mut merged_diff_ids = base_config.rootfs.diff_ids.clone();
merged_diff_ids.push(app_diff_id.to_string());
// Combine history (base history + app history)
let mut merged_history = base_config.history.clone();
merged_history.push(History {
created: chrono::Utc::now().to_rfc3339(),
created_by: "krust".to_string(),
comment: "Built with krust".to_string(),
empty_layer: false,
});
Ok(ImageConfig {
architecture: arch.to_string(),
os: os.to_string(),
architecture: base_config.architecture.clone(),
os: base_config.os.clone(),
config: Config {
env: vec![
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
],
env: merged_env,
cmd: Some(vec![format!("/app/{}", binary_name)]),
working_dir: "/".to_string(),
user: "65532:65532".to_string(), // nonroot user
working_dir: base_config.config.working_dir.clone(),
user: base_config.config.user.clone(),
},
rootfs: RootFs {
fs_type: "layers".to_string(),
diff_ids: vec![layer_digest.to_string()],
diff_ids: merged_diff_ids,
},
history: vec![History {
created: chrono::Utc::now().to_rfc3339(),
created_by: "krust".to_string(),
comment: "Built with krust".to_string(),
empty_layer: false,
}],
history: merged_history,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
fn create_test_binary() -> PathBuf {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"fake binary content").unwrap();
let path = temp_file.path().to_path_buf();
// Keep the file alive by converting to a regular file
std::fs::copy(&path, &path).unwrap();
path
}
fn create_base_image_config() -> ImageConfig {
ImageConfig {
architecture: "amd64".to_string(),
os: "linux".to_string(),
config: Config {
env: vec![
"PATH=/usr/local/bin:/usr/bin:/bin".to_string(),
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt".to_string(),
],
cmd: None,
working_dir: "/".to_string(),
user: "nonroot:nonroot".to_string(),
},
rootfs: RootFs {
fs_type: "layers".to_string(),
diff_ids: vec![
"sha256:base_layer_1".to_string(),
"sha256:base_layer_2".to_string(),
],
},
history: vec![
History {
created: "2023-01-01T00:00:00Z".to_string(),
created_by: "base-image-builder".to_string(),
comment: "Base layer 1".to_string(),
empty_layer: false,
},
History {
created: "2023-01-01T00:01:00Z".to_string(),
created_by: "base-image-builder".to_string(),
comment: "Base layer 2".to_string(),
empty_layer: false,
},
],
}
}
#[test]
fn test_parse_platform() {
let builder = ImageBuilder::new(
PathBuf::from("/tmp/test"),
"test-base".to_string(),
"linux/amd64".to_string(),
);
let (os, arch) = builder.parse_platform().unwrap();
assert_eq!(os, "linux");
assert_eq!(arch, "amd64");
}
#[test]
fn test_create_layered_config_preserves_base_environment() {
let binary_path = create_test_binary();
let builder = ImageBuilder::new(
binary_path,
"test-base".to_string(),
"linux/amd64".to_string(),
);
let base_config = create_base_image_config();
let app_diff_id = "sha256:app_layer_diff_id";
let result = builder
.create_layered_config(&base_config, app_diff_id)
.unwrap();
// Check that base environment variables are preserved
assert!(result
.config
.env
.contains(&"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt".to_string()));
assert!(result.config.env.iter().any(|env| env.starts_with("PATH=")));
// Check that working directory is preserved
assert_eq!(result.config.working_dir, "/");
// Check that user is preserved
assert_eq!(result.config.user, "nonroot:nonroot");
// Check that architecture and OS are preserved
assert_eq!(result.architecture, "amd64");
assert_eq!(result.os, "linux");
}
#[test]
fn test_create_layered_config_combines_diff_ids() {
let binary_path = create_test_binary();
let builder = ImageBuilder::new(
binary_path,
"test-base".to_string(),
"linux/amd64".to_string(),
);
let base_config = create_base_image_config();
let app_diff_id = "sha256:app_layer_diff_id";
let result = builder
.create_layered_config(&base_config, app_diff_id)
.unwrap();
// Check that base diff_ids are preserved and app diff_id is appended
assert_eq!(result.rootfs.diff_ids.len(), 3);
assert_eq!(result.rootfs.diff_ids[0], "sha256:base_layer_1");
assert_eq!(result.rootfs.diff_ids[1], "sha256:base_layer_2");
assert_eq!(result.rootfs.diff_ids[2], "sha256:app_layer_diff_id");
}
#[test]
fn test_create_layered_config_combines_history() {
let binary_path = create_test_binary();
let builder = ImageBuilder::new(
binary_path,
"test-base".to_string(),
"linux/amd64".to_string(),
);
let base_config = create_base_image_config();
let app_diff_id = "sha256:app_layer_diff_id";
let result = builder
.create_layered_config(&base_config, app_diff_id)
.unwrap();
// Check that base history is preserved and app history is appended
assert_eq!(result.history.len(), 3);
assert_eq!(result.history[0].created_by, "base-image-builder");
assert_eq!(result.history[1].created_by, "base-image-builder");
assert_eq!(result.history[2].created_by, "krust");
assert_eq!(result.history[2].comment, "Built with krust");
assert!(!result.history[2].empty_layer);
}
#[test]
fn test_image_config_serialization_compatibility() {
// Test that our ImageConfig can deserialize from a realistic base image config
let json_config = r#"{
"architecture": "amd64",
"os": "linux",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
],
"WorkingDir": "/",
"User": "nonroot:nonroot"
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:b49b96bfa4b2477b73b0b8fe5e4ce383aa1d0026c1e845fb5d43c1f0b8bdb6ac"
]
}
}"#;
let parsed: ImageConfig = serde_json::from_str(json_config).unwrap();
assert_eq!(parsed.architecture, "amd64");
assert_eq!(parsed.os, "linux");
assert_eq!(parsed.config.env.len(), 2);
assert_eq!(parsed.config.working_dir, "/");
assert_eq!(parsed.config.user, "nonroot:nonroot");
assert_eq!(parsed.rootfs.diff_ids.len(), 1);
assert_eq!(parsed.history.len(), 0); // Default empty for missing field
}
}

View file

@ -1,29 +0,0 @@
#[cfg(test)]
mod tests {
use super::super::*;
use std::path::PathBuf;
#[test]
fn test_parse_platform() {
let builder = ImageBuilder::new(
PathBuf::from("/tmp/test"),
"test-base".to_string(),
"linux/amd64".to_string(),
);
let (os, arch) = builder.parse_platform().unwrap();
assert_eq!(os, "linux");
assert_eq!(arch, "amd64");
}
#[test]
fn test_parse_platform_invalid() {
let builder = ImageBuilder::new(
PathBuf::from("/tmp/test"),
"test-base".to_string(),
"invalid-platform".to_string(),
);
assert!(builder.parse_platform().is_err());
}
}

View file

@ -120,20 +120,40 @@ async fn main() -> Result<()> {
platform_str.clone(),
);
let (config_data, layer_data, manifest) = image_builder.build()?;
// Always use layered approach - registry layer will handle cross-registry blob copying
let base_auth = resolve_auth(&base_image)?;
let (config_data, layer_data, manifest) = image_builder
.build(&mut registry_client, &base_auth)
.await?;
// Push platform-specific image if not --no-push
if !no_push {
info!("Pushing image for platform: {}", platform_str);
let layers = vec![(layer_data, manifest.layers[0].media_type.clone())];
// Get auth for the target registry
let push_auth = resolve_auth(&target_repo)?;
// Push platform image by digest only (no tags)
// Get the media type of the application layer (last layer in manifest)
let app_layer_media_type = manifest
.layers
.last()
.map(|l| l.media_type.clone())
.unwrap_or_else(|| {
"application/vnd.docker.image.rootfs.diff.tar.gzip".to_string()
});
// Push layered image (copy base layers if needed + push app layer + manifest)
let (digest_ref, manifest_size) = registry_client
.push_image_by_digest(&target_repo, config_data, layers, &push_auth)
.push_layered_image(
&target_repo,
config_data,
layer_data,
app_layer_media_type,
&manifest,
&push_auth,
&base_image,
&base_auth,
)
.await?;
// Parse platform string

View file

@ -5,9 +5,6 @@ use oci_distribution::{Client, Reference};
use std::str::FromStr;
use tracing::{debug, info};
#[cfg(test)]
mod tests;
pub struct RegistryClient {
client: Client,
}
@ -269,6 +266,244 @@ impl RegistryClient {
Ok(image_ref)
}
/// Push a layered image where only the top layer is new
#[allow(clippy::too_many_arguments)]
pub async fn push_layered_image(
&mut self,
repository: &str,
config_data: Vec<u8>,
new_layer_data: Vec<u8>,
_new_layer_media_type: String,
manifest: &crate::image::Manifest,
auth: &RegistryAuth,
base_image_ref: &str,
base_auth: &RegistryAuth,
) -> Result<(String, usize)> {
// Create a temporary reference for authentication
let temp_ref = format!("{}:temp", repository);
let reference: Reference = temp_ref
.parse()
.context("Failed to parse repository reference")?;
info!("Pushing layered image to {} (digest only)", repository);
// Authenticate with the registry
self.client
.auth(&reference, auth, oci_distribution::RegistryOperation::Push)
.await
.context("Failed to authenticate with registry")?;
// Push config blob
let config_digest = format!("sha256:{}", sha256::digest(&config_data));
debug!("Pushing config blob: {}", config_digest);
self.client
.push_blob(&reference, &config_data, &config_digest)
.await
.context("Failed to push config blob")?;
// Copy base image layers if they don't exist in target registry
let base_reference: Reference = base_image_ref
.parse()
.context("Failed to parse base image reference")?;
// Check if we need to copy base layers (cross-registry scenario)
let base_registry = base_reference.registry();
let target_registry = reference.registry();
let need_copy_layers = base_registry != target_registry;
if need_copy_layers {
info!(
"Copying base image layers from {} to {}",
base_registry, target_registry
);
// Authenticate with base image registry
let base_client = Client::new(oci_distribution::client::ClientConfig::default());
base_client
.auth(
&base_reference,
base_auth,
oci_distribution::RegistryOperation::Pull,
)
.await
.context("Failed to authenticate with base registry")?;
// Copy each base layer (all except the last one which is our app layer)
for layer in &manifest.layers[..manifest.layers.len().saturating_sub(1)] {
debug!("Copying base layer: {}", layer.digest);
// Pull the layer from base registry
let mut layer_data = Vec::new();
let layer_descriptor = oci_distribution::manifest::OciDescriptor {
media_type: layer.media_type.clone(),
digest: layer.digest.clone(),
size: layer.size,
urls: None,
annotations: None,
};
base_client
.pull_blob(&base_reference, &layer_descriptor, &mut layer_data)
.await
.context("Failed to pull base layer")?;
// Push the layer to target registry
self.client
.push_blob(&reference, &layer_data, &layer.digest)
.await
.context("Failed to push copied base layer")?;
}
}
// Push the new application layer
let new_layer_digest = format!("sha256:{}", sha256::digest(&new_layer_data));
debug!("Pushing new application layer: {}", new_layer_digest);
self.client
.push_blob(&reference, &new_layer_data, &new_layer_digest)
.await
.context("Failed to push new layer")?;
// Create manifest with all layers (base + new)
let mut manifest_layers = Vec::new();
for layer in &manifest.layers {
manifest_layers.push(oci_distribution::manifest::OciDescriptor {
media_type: layer.media_type.clone(),
digest: layer.digest.clone(),
size: layer.size,
urls: None,
annotations: None,
});
}
// Create and push manifest
let image_manifest = oci_distribution::manifest::OciImageManifest {
schema_version: 2,
media_type: Some("application/vnd.oci.image.manifest.v1+json".to_string()),
artifact_type: None,
config: oci_distribution::manifest::OciDescriptor {
media_type: "application/vnd.oci.image.config.v1+json".to_string(),
digest: config_digest,
size: config_data.len() as i64,
urls: None,
annotations: None,
},
layers: manifest_layers,
annotations: None,
};
// Wrap in OciManifest enum
let oci_manifest = oci_distribution::manifest::OciManifest::Image(image_manifest);
debug!("Pushing manifest for layered image");
let (manifest_url, digest) = self
.client
.push_manifest_and_get_digest(&reference, &oci_manifest)
.await
.context("Failed to push manifest")?;
info!(
"Successfully pushed layered image to {} (digest: {})",
manifest_url, digest
);
// Build the full image reference with digest only
let registry = reference.registry();
let repository = reference.repository();
let digest_ref = format!("{}/{}@{}", registry, repository, digest);
// Return both the digest ref and the actual manifest size
let manifest_size = serde_json::to_vec(&oci_manifest)?.len();
Ok((digest_ref, manifest_size))
}
/// Fetch the manifest and config for a specific platform of an image
pub async fn fetch_image_data(
&mut self,
image_ref: &str,
platform: &str,
auth: &RegistryAuth,
) -> Result<(
oci_distribution::manifest::OciImageManifest,
crate::image::ImageConfig,
)> {
let reference: Reference = image_ref
.parse()
.context("Failed to parse image reference")?;
debug!(
"Fetching image data for {} platform {}",
reference, platform
);
// Authenticate with the registry
self.client
.auth(&reference, auth, oci_distribution::RegistryOperation::Pull)
.await
.context("Failed to authenticate with registry")?;
// Pull the manifest for the specific platform
let (manifest, _digest) = self
.client
.pull_manifest(&reference, auth)
.await
.context("Failed to pull manifest")?;
// Handle different manifest types
let image_manifest = match manifest {
oci_distribution::manifest::OciManifest::Image(img_manifest) => img_manifest,
oci_distribution::manifest::OciManifest::ImageIndex(index) => {
// Find the manifest for the requested platform
let (os, arch) = platform
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("Invalid platform format: {}", platform))?;
let platform_manifest = index
.manifests
.iter()
.find(|m| {
m.platform
.as_ref()
.is_some_and(|p| p.os == os && p.architecture == arch)
})
.ok_or_else(|| {
anyhow::anyhow!("Platform {} not found in image index", platform)
})?;
// Pull the platform-specific manifest
let platform_ref = format!("{}@{}", image_ref, platform_manifest.digest);
let platform_reference: Reference = platform_ref
.parse()
.context("Failed to parse platform reference")?;
let (platform_manifest, _) = self
.client
.pull_manifest(&platform_reference, auth)
.await
.context("Failed to pull platform manifest")?;
match platform_manifest {
oci_distribution::manifest::OciManifest::Image(img_manifest) => img_manifest,
_ => anyhow::bail!("Expected image manifest, got index"),
}
}
};
// Pull the config blob
let mut config_data = Vec::new();
self.client
.pull_blob(&reference, &image_manifest.config, &mut config_data)
.await
.context("Failed to pull config blob")?;
// Parse the config
let config: crate::image::ImageConfig =
serde_json::from_slice(&config_data).context("Failed to parse image config")?;
Ok((image_manifest, config))
}
/// Fetch the manifest for an image and extract available platforms
pub async fn get_image_platforms(
&mut self,
@ -341,3 +576,91 @@ pub fn parse_image_reference(image: &str) -> Result<(String, String, String)> {
Ok((registry, repository, tag))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image::{Config, History, ImageConfig, RootFs};
#[test]
fn test_parse_image_reference() {
let (registry, repo, tag) =
parse_image_reference("docker.io/library/hello-world:latest").unwrap();
assert_eq!(registry, "docker.io");
assert_eq!(repo, "library/hello-world");
assert_eq!(tag, "latest");
}
#[test]
fn test_parse_image_reference_no_tag() {
let (_, _, tag) = parse_image_reference("docker.io/library/hello-world").unwrap();
assert_eq!(tag, "latest");
}
#[test]
fn test_layered_config_structure() {
// Test layered config creation
let config = ImageConfig {
architecture: "amd64".to_string(),
os: "linux".to_string(),
config: Config {
env: vec![
"PATH=/usr/local/bin:/usr/bin:/bin".to_string(),
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt".to_string(),
],
cmd: Some(vec!["/app/test-binary".to_string()]),
working_dir: "/".to_string(),
user: "nonroot:nonroot".to_string(),
},
rootfs: RootFs {
fs_type: "layers".to_string(),
diff_ids: vec![
"sha256:base_layer_1".to_string(),
"sha256:base_layer_2".to_string(),
"sha256:app_layer".to_string(),
],
},
history: vec![
History {
created: "2023-01-01T00:00:00Z".to_string(),
created_by: "base-image-builder".to_string(),
comment: "Base layer 1".to_string(),
empty_layer: false,
},
History {
created: "2023-01-01T00:01:00Z".to_string(),
created_by: "base-image-builder".to_string(),
comment: "Base layer 2".to_string(),
empty_layer: false,
},
History {
created: "2023-01-01T00:02:00Z".to_string(),
created_by: "krust".to_string(),
comment: "Built with krust".to_string(),
empty_layer: false,
},
],
};
// Test that layered configs preserve base image properties
assert_eq!(config.rootfs.diff_ids.len(), 3);
assert_eq!(config.history.len(), 3);
// Verify diff_ids match expected order
assert_eq!(config.rootfs.diff_ids[0], "sha256:base_layer_1");
assert_eq!(config.rootfs.diff_ids[1], "sha256:base_layer_2");
assert_eq!(config.rootfs.diff_ids[2], "sha256:app_layer");
// Verify history is preserved and extended
assert_eq!(config.history[0].created_by, "base-image-builder");
assert_eq!(config.history[1].created_by, "base-image-builder");
assert_eq!(config.history[2].created_by, "krust");
// Verify base image environment is preserved
assert!(config
.config
.env
.contains(&"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt".to_string()));
assert!(config.config.env.iter().any(|env| env.starts_with("PATH=")));
}
}

View file

@ -1,27 +0,0 @@
#[cfg(test)]
mod tests {
use super::super::*;
#[test]
fn test_parse_image_reference() {
let (registry, repo, tag) =
parse_image_reference("docker.io/library/hello-world:latest").unwrap();
assert_eq!(registry, "docker.io");
assert_eq!(repo, "library/hello-world");
assert_eq!(tag, "latest");
}
#[test]
fn test_parse_image_reference_no_tag() {
let (_, _, tag) = parse_image_reference("docker.io/library/hello-world").unwrap();
assert_eq!(tag, "latest");
}
#[test]
fn test_parse_image_reference_with_port() {
let (registry, repo, tag) = parse_image_reference("localhost:5000/myapp:v1.0").unwrap();
assert_eq!(registry, "localhost:5000");
assert_eq!(repo, "myapp");
assert_eq!(tag, "v1.0");
}
}