aboutsummaryrefslogtreecommitdiff
path: root/src/images.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/images.rs')
-rw-r--r--src/images.rs215
1 files changed, 215 insertions, 0 deletions
diff --git a/src/images.rs b/src/images.rs
new file mode 100644
index 0000000..11daaea
--- /dev/null
+++ b/src/images.rs
@@ -0,0 +1,215 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// 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 <https://www.gnu.org/licenses/>.
+
+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<String, Image>,
+}
+
+fn inspect(project: &Project, name: &str) -> Result<Value> {
+ let records: Vec<Value> = 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<String, Image>,
+ new_tags: &mut Vec<String>,
+) -> 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<Vec<Value>> {
+ 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)
+}