// Copyright (C) 2026 Doan Luu Duc Tai // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . use std::collections::BTreeMap; use std::fs::{self, File}; use std::path::Path; use anyhow::{Context, Result, ensure}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::project::{Mode, Project, Settings, field}; use crate::{files, process}; #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Image { reference: String, architecture: String, layers: Value, id: String, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct ImageLock { format: u32, platform: String, services: BTreeMap, } fn inspect(project: &Project, name: &str) -> Result { let records: Vec = serde_json::from_slice( &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout, )?; records .into_iter() .next() .context("Docker returned no image") } pub(crate) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> { fs::create_dir_all(destination)?; let platform = project.platform()?; let mut services = BTreeMap::new(); let mut new_tags = Vec::new(); let result = save_inner( project, containers, destination, &mut services, &mut new_tags, ); for tag in new_tags { if let Err(error) = process::capture(project.command("podman")?.args(["image", "rm", &tag])) { process::error(format_args!( "Could not remove temporary image tag {tag}: {error:#}" ))?; } } result?; let lock = ImageLock { format: 2, platform, services, }; files::write( &destination.join("image-lock.json"), &serde_json::to_vec_pretty(&lock)?, 0o600, ) } fn save_inner( project: &Project, containers: &[Value], destination: &Path, services: &mut BTreeMap, new_tags: &mut Vec, ) -> Result<()> { for container in containers { let service = field(container, &["Config", "Labels", "io.stack.service"])? .as_str() .context("Missing service label")?; let id = field(container, &["Image"])? .as_str() .context("Missing image ID")?; let suffix: String = id.trim_start_matches("sha256:").chars().take(16).collect(); let alias = format!("localhost/stack-export-{service}:{suffix}"); if !process::available( project .command("podman")? .args(["image", "inspect", &alias]), ) { process::capture( project .command("podman")? .args(["image", "tag", id, &alias]), )?; new_tags.push(alias.clone()); } let actual = inspect(project, &alias)?; services.insert( service.to_owned(), Image { reference: alias, architecture: field(&actual, &["Architecture"])? .as_str() .context("Missing architecture")? .to_owned(), layers: field(&actual, &["RootFS", "Layers"])?.clone(), id: field(&actual, &["Id"])? .as_str() .context("Missing image ID")? .trim_start_matches("sha256:") .to_owned(), }, ); } let mut command = project.command("podman")?; command .args([ "save", "--multi-image-archive", "--format", "docker-archive", "--output", ]) .arg(destination.join("images.tar")); for image in services.values() { command.arg(&image.reference); } process::run(&mut command) } pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> { let path = project.root.join("var/image-lock.json"); if settings.online || !path.is_file() { return Ok(()); } let lock: ImageLock = serde_json::from_reader(File::open(path)?)?; ensure!(lock.format == 2, "Unsupported image lock format"); let platform = project.platform()?; if platform.trim() != lock.platform { ensure!( settings.mode == Mode::Local, "Release images target {}, but this server is {}; build a native release locally", lock.platform, platform.trim() ); settings.online = true; return process::line(format_args!( "Using native images/builds for {}", platform.trim() )); } let missing = lock.services.values().any(|item| { project.command("podman").is_ok_and(|mut command| { !process::available(command.args(["image", "inspect", &item.reference])) }) }); if missing { let archive = project.root.join("var/images.tar"); ensure!( archive.is_file(), "Release image archive is missing; deploy a complete release" ); process::run( project .command("podman")? .args(["image", "load", "--input"]) .arg(archive), )?; } for (service, image) in lock.services { let actual = inspect(project, &image.reference)?; ensure!( field(&actual, &["Architecture"])?.as_str() == Some(image.architecture.as_str()) && field(&actual, &["RootFS", "Layers"])? == &image.layers && field(&actual, &["Id"])? .as_str() .context("Missing image ID")? .trim_start_matches("sha256:") == image.id, "Loaded image does not match its content lock: {service}" ); } Ok(()) } pub(crate) fn configured(project: &Project, settings: &Settings) -> Result> { let config = project.configuration(settings)?; let mut records = Vec::new(); for (service, value) in config.services { let actual = inspect(project, &value.image)?; records.push(serde_json::json!({ "Image": field(&actual, &["Id"])?, "Config": {"Labels": {"io.stack.service": service}} })); } Ok(records) }