#![cfg(target_os = "windows")]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use zlayer_registry::image_config::{ImageConfig, ImageHealthcheck};
use zlayer_registry::oci_export::{OciDescriptor, OciManifest};
use crate::dockerfile::{HealthcheckInstruction, ShellOrExec};
#[derive(Debug, Clone)]
pub struct BaseLayerBlob {
pub media_type: String,
pub digest: String,
pub bytes: Vec<u8>,
pub urls: Vec<String>,
}
pub const OCI_IMAGE_CONFIG_MEDIA_TYPE: &str = "application/vnd.oci.image.config.v1+json";
pub const OCI_IMAGE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
pub const OCI_WINDOWS_LAYER_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+gzip";
#[derive(Debug, Clone)]
pub struct ImageConfigBuilder {
runtime: ImageConfig,
os: String,
architecture: String,
os_version: Option<String>,
}
impl Default for ImageConfigBuilder {
fn default() -> Self {
Self::new()
}
}
impl ImageConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self {
runtime: ImageConfig::default(),
os: "windows".to_string(),
architecture: "amd64".to_string(),
os_version: None,
}
}
pub fn inherit_from_base(&mut self, base: &ImageConfig) {
self.runtime = base.clone();
}
pub fn set_os_version(&mut self, version: Option<String>) {
self.os_version = version;
}
pub fn push_env(&mut self, key: &str, value: &str) {
let entries = self.runtime.env.get_or_insert_with(Vec::new);
let prefix = format!("{key}=");
entries.retain(|e| !e.starts_with(&prefix));
entries.push(format!("{prefix}{value}"));
}
pub fn set_working_dir(&mut self, dir: &str) {
self.runtime.working_dir = Some(dir.to_string());
}
#[must_use]
pub fn current_working_dir(&self) -> Option<String> {
self.runtime.working_dir.clone()
}
#[must_use]
pub fn current_user(&self) -> Option<&str> {
self.runtime.user.as_deref()
}
#[must_use]
pub fn env_map(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
if let Some(ref env) = self.runtime.env {
for entry in env {
if let Some((k, v)) = entry.split_once('=') {
out.insert(k.to_string(), v.to_string());
}
}
}
out
}
pub fn add_exposed_port(&mut self, port: u16, tcp: bool) {
let map = self
.runtime
.exposed_ports
.get_or_insert_with(Default::default);
let proto = if tcp { "tcp" } else { "udp" };
map.insert(
format!("{port}/{proto}"),
serde_json::Value::Object(serde_json::Map::new()),
);
}
pub fn add_label(&mut self, key: &str, value: &str) {
let map = self.runtime.labels.get_or_insert_with(Default::default);
map.insert(key.to_string(), value.to_string());
}
pub fn add_volume(&mut self, path: &str) {
let map = self.runtime.volumes.get_or_insert_with(Default::default);
map.insert(
path.to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
}
pub fn set_user(&mut self, user: &str) {
self.runtime.user = Some(user.to_string());
}
pub fn set_entrypoint(
&mut self,
translator: &crate::buildah::DockerfileTranslator,
cmd: &ShellOrExec,
) {
self.runtime.entrypoint = Some(shellorexec_to_vec(translator, cmd));
}
pub fn set_cmd(
&mut self,
translator: &crate::buildah::DockerfileTranslator,
cmd: &ShellOrExec,
) {
self.runtime.cmd = Some(shellorexec_to_vec(translator, cmd));
}
pub fn set_shell(&mut self, shell: Vec<String>) {
self.runtime.shell = Some(shell);
}
pub fn set_stop_signal(&mut self, signal: &str) {
self.runtime.stop_signal = Some(signal.to_string());
}
pub fn set_healthcheck(&mut self, hc: HealthcheckInstruction) {
match hc {
HealthcheckInstruction::None => {
self.runtime.healthcheck = Some(ImageHealthcheck {
test: Some(vec!["NONE".to_string()]),
..Default::default()
});
}
HealthcheckInstruction::Check {
command,
interval,
timeout,
start_period,
retries,
..
} => {
let mut test_vec = Vec::new();
match &command {
ShellOrExec::Shell(s) => {
test_vec.push("CMD-SHELL".to_string());
test_vec.push(s.clone());
}
ShellOrExec::Exec(args) => {
test_vec.push("CMD".to_string());
test_vec.extend(args.iter().cloned());
}
}
self.runtime.healthcheck = Some(ImageHealthcheck {
test: Some(test_vec),
#[allow(clippy::cast_possible_truncation)]
interval: interval.map(|d| d.as_nanos() as u64),
#[allow(clippy::cast_possible_truncation)]
timeout: timeout.map(|d| d.as_nanos() as u64),
#[allow(clippy::cast_possible_truncation)]
start_period: start_period.map(|d| d.as_nanos() as u64),
retries,
});
}
}
}
#[must_use]
pub fn runtime(&self) -> &ImageConfig {
&self.runtime
}
}
#[derive(Debug, Serialize, Deserialize)]
struct OciImageConfig {
architecture: String,
os: String,
#[serde(rename = "os.version", skip_serializing_if = "Option::is_none")]
os_version: Option<String>,
config: ImageConfig,
rootfs: RootFs,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
history: Vec<HistoryEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
created: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct RootFs {
r#type: String,
diff_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct HistoryEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
created_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
empty_layer: Option<bool>,
}
pub fn build_image_config_bytes(
builder: &ImageConfigBuilder,
base_diff_ids: &[String],
new_layer_diff_id: &str,
) -> serde_json::Result<Vec<u8>> {
let mut diff_ids: Vec<String> = base_diff_ids.to_vec();
diff_ids.push(new_layer_diff_id.to_string());
let doc = OciImageConfig {
architecture: builder.architecture.clone(),
os: builder.os.clone(),
os_version: builder.os_version.clone(),
config: builder.runtime.clone(),
rootfs: RootFs {
r#type: "layers".to_string(),
diff_ids,
},
history: Vec::new(),
created: Some("1970-01-01T00:00:00Z".to_string()),
};
serde_json::to_vec(&doc)
}
pub fn build_manifest_bytes(
config_digest: &str,
config_size: u64,
base_layers: &[BaseLayerBlob],
new_layer_digest: &str,
new_layer_size: u64,
) -> serde_json::Result<Vec<u8>> {
let mut layers: Vec<OciDescriptor> = base_layers
.iter()
.map(|layer| OciDescriptor {
media_type: layer.media_type.clone(),
digest: layer.digest.clone(),
size: layer.bytes.len() as u64,
urls: if layer.urls.is_empty() {
None
} else {
Some(layer.urls.clone())
},
annotations: None,
platform: None,
})
.collect();
layers.push(OciDescriptor {
media_type: OCI_WINDOWS_LAYER_MEDIA_TYPE.to_string(),
digest: new_layer_digest.to_string(),
size: new_layer_size,
urls: None,
annotations: None,
platform: None,
});
let manifest = OciManifest {
schema_version: 2,
media_type: Some(OCI_IMAGE_MANIFEST_MEDIA_TYPE.to_string()),
config: Some(OciDescriptor {
media_type: OCI_IMAGE_CONFIG_MEDIA_TYPE.to_string(),
digest: config_digest.to_string(),
size: config_size,
urls: None,
annotations: None,
platform: None,
}),
layers,
annotations: None,
};
serde_json::to_vec(&manifest)
}
#[cfg(test)]
mod tests {
use super::*;
fn demo_config() -> ImageConfigBuilder {
let mut b = ImageConfigBuilder::new();
b.set_working_dir("C:\\app");
b.push_env("PATH", "C:\\Windows;C:\\Windows\\System32");
b.add_label("example", "true");
b.set_os_version(Some("10.0.20348.2600".to_string()));
b
}
#[test]
fn image_config_json_has_windows_amd64_identity() {
let cfg = demo_config();
let bytes = build_image_config_bytes(&cfg, &[], "sha256:deadbeef").unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed["os"], "windows");
assert_eq!(parsed["architecture"], "amd64");
assert_eq!(parsed["os.version"], "10.0.20348.2600");
assert_eq!(parsed["rootfs"]["type"], "layers");
assert_eq!(parsed["rootfs"]["diff_ids"][0], "sha256:deadbeef");
}
#[test]
fn manifest_uses_standard_oci_media_types() {
let bytes = build_manifest_bytes(
"sha256:aaa",
123,
&[BaseLayerBlob {
media_type: "application/vnd.oci.image.layer.v1.tar+gzip".to_string(),
digest: "sha256:base".to_string(),
bytes: vec![0; 64],
urls: vec![],
}],
"sha256:new",
64,
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed["schemaVersion"], 2);
assert_eq!(parsed["mediaType"], OCI_IMAGE_MANIFEST_MEDIA_TYPE);
assert_eq!(parsed["config"]["mediaType"], OCI_IMAGE_CONFIG_MEDIA_TYPE);
assert_eq!(parsed["config"]["digest"], "sha256:aaa");
assert_eq!(
parsed["layers"][1]["mediaType"],
OCI_WINDOWS_LAYER_MEDIA_TYPE
);
assert_eq!(parsed["layers"][1]["digest"], "sha256:new");
}
#[test]
fn env_map_parses_key_value_list() {
let mut b = ImageConfigBuilder::new();
b.push_env("FOO", "bar");
b.push_env("BAZ", "qux=with=equals");
let map = b.env_map();
assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(map.get("BAZ").map(String::as_str), Some("qux=with=equals"));
}
#[test]
fn push_env_replaces_existing_key() {
let mut b = ImageConfigBuilder::new();
b.push_env("PATH", "/old");
b.push_env("PATH", "/new");
let env = b.runtime.env.as_ref().unwrap();
let path_entries: Vec<_> = env.iter().filter(|e| e.starts_with("PATH=")).collect();
assert_eq!(path_entries.len(), 1);
assert_eq!(path_entries[0], "PATH=/new");
}
#[test]
fn shellorexec_to_vec_exec_form_is_verbatim() {
use crate::backend::ImageOs;
let t = crate::buildah::DockerfileTranslator::new(ImageOs::Windows);
let v = shellorexec_to_vec(
&t,
&ShellOrExec::Exec(vec![
"myapp.exe".to_string(),
"--flag".to_string(),
"value".to_string(),
]),
);
assert_eq!(v, vec!["myapp.exe", "--flag", "value"]);
}
#[test]
fn shellorexec_to_vec_shell_form_wraps_in_active_shell() {
use crate::backend::ImageOs;
let mut t = crate::buildah::DockerfileTranslator::new(ImageOs::Windows);
t.set_shell_override(vec!["powershell".to_string(), "-Command".to_string()]);
let v = shellorexec_to_vec(&t, &ShellOrExec::Shell("Get-Process".to_string()));
assert_eq!(v, vec!["powershell", "-Command", "Get-Process"]);
}
}
fn shellorexec_to_vec(
translator: &crate::buildah::DockerfileTranslator,
cmd: &ShellOrExec,
) -> Vec<String> {
match cmd {
ShellOrExec::Exec(args) => args.clone(),
ShellOrExec::Shell(s) => {
let mut out = translator.active_shell();
out.push(s.clone());
out
}
}
}