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:
commit
09f78c7047
6 changed files with 629 additions and 99 deletions
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
298
src/image/mod.rs
298
src/image/mod.rs
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
30
src/main.rs
30
src/main.rs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue