mirror of
https://github.com/imjasonh/krust
synced 2026-07-19 07:24:38 +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() {
|
fn main() {
|
||||||
println!("Hello from krust example!");
|
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 anyhow::{Context, Result};
|
||||||
use flate2::write::GzEncoder;
|
use flate2::write::GzEncoder;
|
||||||
use flate2::Compression;
|
use flate2::Compression;
|
||||||
|
use oci_distribution::secrets::RegistryAuth;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha256::digest;
|
use sha256::digest;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
|
|
@ -8,27 +9,25 @@ use std::io::Write;
|
||||||
use tar::Builder;
|
use tar::Builder;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ImageConfig {
|
pub struct ImageConfig {
|
||||||
pub architecture: String,
|
pub architecture: String,
|
||||||
pub os: String,
|
pub os: String,
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub rootfs: RootFs,
|
pub rootfs: RootFs,
|
||||||
|
#[serde(default)]
|
||||||
pub history: Vec<History>,
|
pub history: Vec<History>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(rename = "Env")]
|
#[serde(rename = "Env", default)]
|
||||||
pub env: Vec<String>,
|
pub env: Vec<String>,
|
||||||
#[serde(rename = "Cmd")]
|
#[serde(rename = "Cmd")]
|
||||||
pub cmd: Option<Vec<String>>,
|
pub cmd: Option<Vec<String>>,
|
||||||
#[serde(rename = "WorkingDir")]
|
#[serde(rename = "WorkingDir", default)]
|
||||||
pub working_dir: String,
|
pub working_dir: String,
|
||||||
#[serde(rename = "User")]
|
#[serde(rename = "User", default)]
|
||||||
pub user: String,
|
pub user: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,7 +42,9 @@ pub struct RootFs {
|
||||||
pub struct History {
|
pub struct History {
|
||||||
pub created: String,
|
pub created: String,
|
||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
|
#[serde(default)]
|
||||||
pub comment: String,
|
pub comment: String,
|
||||||
|
#[serde(default)]
|
||||||
pub empty_layer: bool,
|
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");
|
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
|
// Create application layer
|
||||||
let (layer_data, diff_id) = self.create_layer()?;
|
let (app_layer_data, app_diff_id) = self.create_layer()?;
|
||||||
let layer_digest = format!("sha256:{}", digest(&layer_data));
|
let app_layer_digest = format!("sha256:{}", digest(&app_layer_data));
|
||||||
let layer_size = layer_data.len() as i64;
|
let app_layer_size = app_layer_data.len() as i64;
|
||||||
|
|
||||||
// Create image config
|
// Combine base image layers with application layer
|
||||||
let config = self.create_config(&os, &arch, &diff_id)?;
|
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_data = serde_json::to_vec_pretty(&config)?;
|
||||||
let config_digest = format!("sha256:{}", digest(&config_data));
|
let config_digest = format!("sha256:{}", digest(&config_data));
|
||||||
let config_size = config_data.len() as i64;
|
let config_size = config_data.len() as i64;
|
||||||
|
|
@ -108,14 +140,10 @@ impl ImageBuilder {
|
||||||
size: config_size,
|
size: config_size,
|
||||||
digest: config_digest,
|
digest: config_digest,
|
||||||
},
|
},
|
||||||
layers: vec![Descriptor {
|
layers: all_layers,
|
||||||
media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string(),
|
|
||||||
size: layer_size,
|
|
||||||
digest: layer_digest,
|
|
||||||
}],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((config_data, layer_data, manifest))
|
Ok((config_data, app_layer_data, manifest))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_platform(&self) -> Result<(String, String)> {
|
fn parse_platform(&self) -> Result<(String, String)> {
|
||||||
|
|
@ -163,7 +191,11 @@ impl ImageBuilder {
|
||||||
Ok((compressed, diff_id))
|
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
|
let binary_name = self
|
||||||
.binary_path
|
.binary_path
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|
@ -171,27 +203,225 @@ impl ImageBuilder {
|
||||||
.to_str()
|
.to_str()
|
||||||
.context("Invalid UTF-8 in binary name")?;
|
.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 {
|
Ok(ImageConfig {
|
||||||
architecture: arch.to_string(),
|
architecture: base_config.architecture.clone(),
|
||||||
os: os.to_string(),
|
os: base_config.os.clone(),
|
||||||
config: Config {
|
config: Config {
|
||||||
env: vec![
|
env: merged_env,
|
||||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
|
|
||||||
],
|
|
||||||
cmd: Some(vec![format!("/app/{}", binary_name)]),
|
cmd: Some(vec![format!("/app/{}", binary_name)]),
|
||||||
working_dir: "/".to_string(),
|
working_dir: base_config.config.working_dir.clone(),
|
||||||
user: "65532:65532".to_string(), // nonroot user
|
user: base_config.config.user.clone(),
|
||||||
},
|
},
|
||||||
rootfs: RootFs {
|
rootfs: RootFs {
|
||||||
fs_type: "layers".to_string(),
|
fs_type: "layers".to_string(),
|
||||||
diff_ids: vec![layer_digest.to_string()],
|
diff_ids: merged_diff_ids,
|
||||||
},
|
},
|
||||||
history: vec![History {
|
history: merged_history,
|
||||||
created: chrono::Utc::now().to_rfc3339(),
|
|
||||||
created_by: "krust".to_string(),
|
|
||||||
comment: "Built with krust".to_string(),
|
|
||||||
empty_layer: false,
|
|
||||||
}],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(),
|
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
|
// Push platform-specific image if not --no-push
|
||||||
if !no_push {
|
if !no_push {
|
||||||
info!("Pushing image for platform: {}", platform_str);
|
info!("Pushing image for platform: {}", platform_str);
|
||||||
|
|
||||||
let layers = vec![(layer_data, manifest.layers[0].media_type.clone())];
|
|
||||||
|
|
||||||
// Get auth for the target registry
|
// Get auth for the target registry
|
||||||
let push_auth = resolve_auth(&target_repo)?;
|
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
|
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?;
|
.await?;
|
||||||
|
|
||||||
// Parse platform string
|
// Parse platform string
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@ use oci_distribution::{Client, Reference};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
pub struct RegistryClient {
|
pub struct RegistryClient {
|
||||||
client: Client,
|
client: Client,
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +266,244 @@ impl RegistryClient {
|
||||||
Ok(image_ref)
|
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
|
/// Fetch the manifest for an image and extract available platforms
|
||||||
pub async fn get_image_platforms(
|
pub async fn get_image_platforms(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -341,3 +576,91 @@ pub fn parse_image_reference(image: &str) -> Result<(String, String, String)> {
|
||||||
|
|
||||||
Ok((registry, repository, tag))
|
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